Three Most Recent Posts, One Per Term

I have really tried to think this out, and I have came to the conclusion that there is really no other way natively to do this. Your method might be another route to take, but as it stands, it is quite expensive to run.

What I have come to is this:

  • Get all the posts in one query associated with the post type and taxonomy. To stream line the query, we will only query the post ID’s. This will increase performance dramatically as it saves up to 99.9% on db queries and time spend to run these queries.

  • We will loop through these post ID’s and use get_the_terms() to get the terms associated with the post. It should be noted that the results from get_the_terms() are cached, so this is also very lean.

  • We will then loop through the post terms and store them in an array and then compare these to the adjacent post’s terms. The first three post ID’s will be saved in an array. This will be the 3 most recent posts, each one from a unique category. To speed up our loop, we will use break to bail out once we have what we are looking for

  • We will also implement some system in order that we do not run this on every page load

OK, so lets tackle the issue and code it. Before we do, a couple of notes.

IMPORTANT NOTES:

  • The code is completely untested. It might therefor be buggy. Be sure to test this locally and with debug turned on

  • Requires at least PHP 5.4.

  • Modify and abuse the code as you see fit to suit your needs

THE CODED IDEA

We will be using a function which we will be able to call later in the appropriate place

/**
 * Function get_unique_term_recent_posts
 *
 * Get most recent posts but each post should be from a
 * unique term
 *
 * @see http://wordpress.stackexchange.com/q/206100/31545
 * @param (string) $post type Post type to get posts from
 * @param (string) $taxonoy Taxonomy to get posts from
 * @param (int|string) $number Amount of posts to get
 * @return $unique_ids
 *
 */
function get_unique_term_recent_posts( $post_type="post", $taxonomy = 'category', $number = 3 )
{
    // Make sure that no empty values are passed, if so, return false
    if (    !$post_type 
         || !$taxonomy
         || !$number
    ) 
        return false;

    // Validate and sanitize input values
    $post_type = filter_var( $post_type, FILTER_SANITIZE_STRING );
    $taxonomy  = filter_var( $taxonomy,  FILTER_SANITIZE_STRING );
    $number    = filter_var( $number,    FILTER_VALIDATE_INT    );

    // Make sure that our taxonomy exist to avoid WP_Error objects when querying post terms, return false
    if ( !taxonomy_exists( $taxonomy ) )
        return false;

    // Save everything in a transient to avoid this being run on every page load
    if ( false === ( $unique_ids = get_transient( 'posts_list_' . md5( $taxonomy . $post_type . $number ) ) ) ) {

        // Run our query to get the posts
        $args = [
            'post_type'      => $post_type,
            'posts_per_page' => -1,
            'fields'         => 'ids' // only get post ids, all we need here
        ];
        $q = get_posts( $args );

        // Make sure we have posts before we continue, if not, return false
        if ( !$q )
            return false;

        // Lets update the object term cache
        update_object_term_cache( $q, $post_type );

        // Set up the varaible which will hold the post ID's
        $unique_ids = [];
        // Set up a variable which will hold term ID for comparion
        $term_id_array = [];
        // Set up a helper variable which will hold temp term ID's for comparion
        $term_helper_array = [];
        // Start a counter to count posts with terms from $taxonomy
        $counter = 0;

        // Loop through the posts, get the post terms, loop through them and build an array of post ID's
        foreach ( $q as $post_id ) { 

            // Break the foreach loop if the amount of ids in $unique_ids == $number
            if ( count( $unique_ids ) == $number )
                break;

            $terms = get_object_term_cache( $post_id, $taxonomy );

            // If post does not have terms, skip post and continue
            if ( !$terms ) 
                continue;

            /**
             * If $term_id_array is empty, this means that this is the first post/newest
             * post that will have a unique term, lets save the post ID
             */
            if (    !$term_id_array
                 && $counter == 0
            )
                $unique_ids[] = $post_id;

            // We do have post terms, loop through them
            foreach ( $terms as $term_key=>$term ) {

                // Add all term ID's in array. Skip comparison on first post, just save the terms
                if ( $counter == 0 ) {
                    $term_id_array[] = $term->term_id;
                } else { 

                    if ( in_array( $term->term_id, $term_id_array ) ) {

                        // Reset the $term_helper_array back to an empty array
                        $term_helper_array = [];

                        // Break the loop if the condition evaluate to true
                        break;
                    } 
                        // Term ID not found, update the $term_helper_array with the current term ID
                        $term_helper_array[] = $term->term_id;

                } //endif $counter == 0

            } // endforeach $terms

            /**
             * If our helper array, $term_helper_array have terms, we should move them to the $term_id_array array
             * and then reset the $term_helper_array back to an empty array. 
             *
             * In short, if $term_helper_array have terms, it means that the specific post has unique terms
             * compare to the previous post being save in $unique_ids
             */
            if ( $term_helper_array ) {
                // If no term ID mathes an ID in the $term_id_array, save the post ID
                $unique_ids[] = $post_id;
                /**
                 * Merge the $term_id_array and $term_helper_array. This will hold
                 * only terms of the post that are stored in the $unique_ids array
                 */
                $term_id_array = array_merge( $term_id_array, $term_helper_array );

                // Reset the $term_helper_array back to an empty array
                $term_helper_array = [];
            }

            // Update our counter
            $counter++;

        } // endforeach $q   

    set_transient( 'posts_list_' . md5( $taxonomy . $post_type . $number ), $unique_ids, 30 * DAY_IN_SECONDS );
    }
    /**
     * We can now return $unique_ids which should hold at most 3 post ID's, 
     * each having a unique term    
     */
    return $unique_ids;
}   

This function should work perfectly with posts that have multiple terms assigned to it (which is normally the case). If a post A have term A and Term B assinged to it, at post B have term B and term C assigned to it, post B will not be displayed in the returned array of ID’s as you would want to have two posts which have unique terms assigned to it.

You can now use the function as follow:

$post_type="webcomic1";
$taxonomy = 'webcomic1_storyline';
$post_ids = get_unique_term_recent_posts( $post_type, $taxonomy );
if ( $post_ids ) {
    $args = [
        'post__in' => $post_ids,
        'post_type' => $post_type,
        'posts_per_page' => 3
    ];
    $q = new WP_Query( $args );

    if ( $q->have_posts() ) {
        while ( $q->have_posts() ) {
        $q->the_post();

            // Your template tags and mark up

        } // endwhile
        wp_reset_postdata();
    } // endif have_posts()
} // endif $post_ids

I have set the transient to expire in 30 days, you can set this a needed. But for more to the point control, you would want to delete this transient when a new post is published, deleted, updated or undeleted. For this, we will delete the transient whenever the transition_post_status hook fires. his hook fires on all the previously named conditions.

You can do the following

add_action( 'transition_post_status', function ()
{
    global $wpdb;
    $wpdb->query( "DELETE FROM $wpdb->options WHERE `option_name` LIKE ('_transient%_posts_list_%')" );
    $wpdb->query( "DELETE FROM $wpdb->options WHERE `option_name` LIKE ('_transient_timeout%_posts_list_%')" );
});

EDIT

The code is now tested and working as expected with a couple of bug fixes.

Just on the performance of the function

  • Without the transient -> 2db queries in 0.27 seconds.

  • With the transient -> 2db queries in 0.007 seconds.

As you can see, the db calls stay the same, the only reason for the transient is to save memory as the function uses a bit of memory to run without the transient, also, the PHP functions used also take quite a lot of time to execut. So in essence, the transient is used tosave on time and memory, which is really useful on really big sites