По возможности, я строю SQL постепенно, не в последнюю очередь потому, что он дает мне возможность тестировать по ходу работы.
Первое, что нам нужно сделать, это определить категории верхнего уровня:
SELECT category_id AS tl_cat_id,
category_name AS tl_cat_name,
display_order AS tl_disp_order
FROM Categories
WHERE parent_id = 0;
Теперь нам нужно объединить это с категориями и подкатегориями, чтобы получить результат:
SELECT t.tl_cat_id, t.cat_name, t.tl_disp_order, c.category_id, c.category_name,
CASE WHEN c.parent_id = 0 THEN 0 ELSE c.display_order END AS disp_order
FROM Categories AS c
JOIN (SELECT category_id AS tl_cat_id,
category_name AS tl_cat_name,
display_order AS tl_disp_order
FROM Categories
WHERE parent_id = 0) AS t
ON c.tl_cat_id = t.parent_id OR (c.parent_id = 0 AND t.tl_cat_id = c.category_id)
ORDER BY tl_disp_order, disp_order;
Условие соединения необычное, но должно работать; он собирает строки, в которых идентификатор родителя совпадает с идентификатором текущей категории, или строки, в которых идентификатор родителя равен 0, но идентификатор категории совпадает. Порядок в этом случае почти тривиален - за исключением того, что когда вы имеете дело с упорядочением подкатегории, вы хотите, чтобы родительский элемент был в начале списка. Выражение CASE обрабатывает это отображение.