This query will handle two levels of hierarchy in your taxonomy. More than two levels of hierarchy and you'll need a recursive self-join.
What this does is return the posts in the correct child within parent order. To create the appropriate parent level headings, you'll have compare the current post's parent taxon with that of the prior post. Print the taxon heading at the top of the page, and another level at each point where the parent taxon value changes. You'll need to call a function on each post to get the parent level taxon, as I don't know of any way to pass the value from the query into the post object.
SELECT *
from wp_posts
LEFT JOIN wp_term_relationships ON wp_posts.ID = wp_term_relationships.object_id
LEFT JOIN wp_term_taxonomy ON (wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id)
LEFT JOIN wp_terms ON (wp_term_taxonomy.term_id = wp_terms.term_id)
LEFT JOIN wp_term_taxonomy AS parent ON (wp_term_taxonomy.parent = parent.term_taxonomy_id)
LEFT JOIN wp_terms AS parent_terms ON (parent.term_id = parent_terms.term_id)
WHERE wp_posts.post_type = 'post'
AND wp_term_taxonomy.taxonomy = 'category'
and not exists(select 1
from wp_posts AS subposts
LEFT JOIN wp_term_relationships as subtr ON subposts.ID = subtr.object_id
LEFT JOIN wp_term_taxonomy as subtt ON (subtr.term_taxonomy_id = subtt.term_taxonomy_id)
LEFT JOIN wp_terms as subt ON (subtt.term_id = subt.term_id)
LEFT JOIN wp_term_taxonomy AS subparent ON (subtt.parent = subparent.term_taxonomy_id)
where subtt.parent > wp_term_taxonomy.parent
and subposts.ID = wp_posts.ID
AND subtt.taxonomy = wp_term_taxonomy.taxonomy)
ORDER BY IFNULL( parent_terms.slug , wp_terms.slug) ASC, wp_terms.slug ASC;
Given that this is the correct SQL for your archive page, let's consider how to implement using filters, such as pre_get_posts, etc.
UPDATE
The SQL query above has been tested and modified to return correct results. The following, similar to my original query, returns two rows for any post that has a parent.
SELECT wp_posts.ID, parent_terms.slug parent_slug, wp_terms.slug, wp_term_taxonomy.taxonomy, wp_term_taxonomy.parent
, IFNULL( parent_terms.slug , wp_terms.slug) sort_col2
from wp_posts
LEFT JOIN wp_term_relationships ON wp_posts.ID = wp_term_relationships.object_id
LEFT JOIN wp_term_taxonomy ON (wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id)
LEFT JOIN wp_terms ON (wp_term_taxonomy.term_id = wp_terms.term_id)
LEFT JOIN wp_term_taxonomy AS parent ON (wp_term_taxonomy.parent = parent.term_taxonomy_id)
LEFT JOIN wp_terms AS parent_terms ON (parent.term_id = parent_terms.term_id)
WHERE wp_posts.post_type = 'post'
AND wp_term_taxonomy.taxonomy = 'category'
ORDER BY IFNULL( parent_terms.slug , wp_terms.slug) ASC, wp_terms.slug ASC;
Note that post.id = 629 appears twice in the results:
+-----+-------------+---------------+----------+--------+-----------+
| ID | parent_slug | slug | taxonomy | parent | sort_col2 |
+-----+-------------+---------------+----------+--------+-----------+
| 629 | | business | category | 0 | business |
| 629 | business | press-release | category | 3 | business |
| 618 | | media | category | 0 | media |
| 608 | | media | category | 0 | media |
| 624 | | startups | category | 0 | startups |
| 621 | | startups | category | 0 | startups |
+-----+-------------+---------------+----------+--------+-----------+
6 rows in set (0.00 sec)
The duplicate rows are filtered out by adding a NOT EXISTS(...)
condition:
SELECT wp_posts.ID, parent_terms.slug parent_slug, wp_terms.slug, wp_term_taxonomy.taxonomy, wp_term_taxonomy.parent
, IFNULL( parent_terms.slug , wp_terms.slug) sort_col2
from wp_posts
LEFT JOIN wp_term_relationships ON wp_posts.ID = wp_term_relationships.object_id
LEFT JOIN wp_term_taxonomy ON (wp_term_relationships.term_taxonomy_id = wp_term_taxonomy.term_taxonomy_id)
LEFT JOIN wp_terms ON (wp_term_taxonomy.term_id = wp_terms.term_id)
LEFT JOIN wp_term_taxonomy AS parent ON (wp_term_taxonomy.parent = parent.term_taxonomy_id)
LEFT JOIN wp_terms AS parent_terms ON (parent.term_id = parent_terms.term_id)
WHERE wp_posts.post_type = 'post'
AND wp_term_taxonomy.taxonomy = 'category'
and not exists(select 1
from wp_posts AS subposts
LEFT JOIN wp_term_relationships as subtr ON subposts.ID = subtr.object_id
LEFT JOIN wp_term_taxonomy as subtt ON (subtr.term_taxonomy_id = subtt.term_taxonomy_id)
LEFT JOIN wp_terms as subt ON (subtt.term_id = subt.term_id)
LEFT JOIN wp_term_taxonomy AS subparent ON (subtt.parent = subparent.term_taxonomy_id)
where subtt.parent > wp_term_taxonomy.parent
and subposts.ID = wp_posts.ID )
ORDER BY IFNULL( parent_terms.slug , wp_terms.slug) ASC, wp_terms.slug ASC;
And the results, posts sorted by child within parent, and no duplicate records:
+-----+-------------+---------------+----------+--------+-----------+
| ID | parent_slug | slug | taxonomy | parent | sort_col2 |
+-----+-------------+---------------+----------+--------+-----------+
| 629 | business | press-release | category | 3 | business |
| 618 | | media | category | 0 | media |
| 608 | | media | category | 0 | media |
| 624 | | startups | category | 0 | startups |
| 621 | | startups | category | 0 | startups |
+-----+-------------+---------------+----------+--------+-----------+
5 rows in set (0.00 sec)
UPDATE: Function NOT current with the latest SQL above
function wpse69290_query( $pieces, $obj )
{
global $wpdb;
#$pieces['fields'] = "* ";
$pieces['join'] .= " LEFT JOIN `$wpdb->term_relationships` AS trs ON ($wpdb->posts.ID = trs.object_id)";
$pieces['join'] .= " LEFT JOIN `$wpdb->term_taxonomy` AS tt ON (trs.term_taxonomy_id = tt.term_taxonomy_id)";
$pieces['join'] .= " LEFT JOIN `$wpdb->terms` AS t ON (tt.term_id = t.term_id)";
$pieces['join'] .= " LEFT JOIN `$wpdb->term_taxonomy` AS parent ON (parent.parent = trs.term_taxonomy_id)";
$pieces['join'] .= " LEFT JOIN `$wpdb->terms` AS parent_terms ON (parent.term_id = parent_terms.term_id)";
$pieces['where'] .= " AND (tt.taxonomy = 'topics')";
$pieces['orderby'] = "IFNULL(parent_terms.slug, t.slug) ASC";
$pieces['limits'] = "LIMIT 0, 999";
return $pieces;
}
add_filter( 'posts_clauses', 'wpse69290_query', 10, 2 );
And yes, no need to use prepare()
for query parts that only have the default table names.