0

I'm trying to figure out how to write a custom query for a Custom Post Type, and Taxonomy where I would retrieve the most recent posts, but with a twist: only one post per unique term.

So if I retrieve three posts, they would all belong to a separate term.

It's possible that a one term may have more recent posts than those queried, but I would like each post to belong to a unique term.

I know how to retrieve a post for all terms (see below), but how would I modify this loop for the above scenario? Appreciate any advice or inputs out there!

<?php
    // Retrieve the terms from our custom tax.
    $taxonomy = array(
                'webcomic1_storyline'
                );

    $args = array(
                'orderby' => 'ID',
                'order' => 'DESC',
                'hide_empty' => false,
                );

    $categories = get_terms($taxonomy,$args);

    // Loop through each term below.
    foreach ( $categories as $category ) : 
    ?>
      <div class="row">
        <section id="<?php echo $category->slug; ?>" class="large-12 columns" data-magellan-destination='<?php echo $category->slug; ?>'> 
           <h3><?php echo $category->name; ?></h3>

           <ul class="work-thumb-container large-block-grid-4 small-block-grid-2">

            <?php 
              // Query below retrieves a single latest post for each term.
              $args = array(
                'posts_per_page' => 1,
                'post_type' => 'webcomic1',
                'webcomic1_storyline' => $category->slug,
                'no_found_rows' => true,
              );

              $the_query = new WP_Query( $args );

              while ( $the_query->have_posts() ) : $the_query->the_post();
            ?>

              <li>
                  <figure class="work-thumb large-12 columns">
                    <a class="cover" href="<?php the_permalink(); ?>"></a>
                        <?php
                        if ( has_post_thumbnail() ) {
                          $image_id       = get_post_thumbnail_id();
                          $image_title    = esc_attr( get_the_title( $image_id ) );
                          $image_link     = wp_get_attachment_url( $image_id );
                          echo '<img class="th" src="'. $image_link .'" alt="'. $image_title .'">';
                        } else {
                          echo '<img class="th" src="'.get_stylesheet_directory_uri(). '/img/shop.jpg'. '" alt="Overboard cover">';
                        }
                      ?>

                    <figcaption>
                      <?php 
                      $term_list = wp_get_post_terms($post->ID, 'webcomic1_storyline', array("fields" => "all")); ?>
                      <h3 class="teaser-title"><a href="<?php the_permalink(); ?>"><?php echo $term_list[0]->name; ?></a></h3>

                      <time class="storyline-updated teaser-time" datetime="<?php echo get_the_time('c'); ?>">
                      <a href="<?php the_permalink(); ?>"> <?php echo get_the_time('Y'); ?> </a> 
                      </time>
                    </figcaption>
                  </figure>
              </li>

            <?php endwhile; ?>

          </ul>
        </section>
      </div><!-- .row -->     

  <?php endforeach; ?>
</section>

1 Answer 1

5

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

4
  • Pieter, thank you very much for your very in depth explanation, and for laying out the process in detail. When I have time (in the next day or two) to thoroughly review your code and try it out, I'll let you know if it solves my problem.
    – tnog
    Oct 22, 2015 at 6:48
  • No problem, it is my my pleasure. Please take your time and test it thouroughly. Feel free to leave comments if you find any bugs in the code. Enjoy ;-) Oct 22, 2015 at 6:50
  • I had a chance to implement and test your code. It worked perfectly... Except for some reason when I set '$number = 3' it was only returning 2 posts. After setting the value to 4 it returned 3. Trying to figure out why.
    – tnog
    Oct 25, 2015 at 18:59
  • It seems that you have a bad filter somewhere in your theme or in a plugin which offsets your query by one. I have quickly retested my code and it does return there posts as expected if $number is set to 3. Look for any instance of pre_get_posts. This is usually the issue as this is the number one action that is incorrectly used Oct 25, 2015 at 19:22

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.