How to get posts from two categories with WP_Query?

In my experience using 'posts_*' filters ('posts_request', 'posts_where'…) in combination with str_replace / preg_replace is unreliable and unflexible:

Unreliable because if another filter modify uses one of that filters, in better case one gets unexpected results, in worst cases one gets SQL errors.

Unflexible because changing an argument, e.g. ‘include_children’ for categories, or reuse the code for e.g. 3 terms instead of 2 need a lot of works.

Moreover, adapt code to be musltisite compatibe need to edit SQL manually.

So, sometimes, evevn if not the best solution regarding performance, a more canonical approach is the best and more flexible one.

And performance can be improced with some caching tricks…

My propose:

  1. write a function taht make use of usort to order posts coming from different queries (e.g. one per term)
  2. write a function that on first run run separate queries, merge results, order them, cache them and return them. On subsequent request simply return the cached results
  3. handle caching invalidation when needed

Code

First the function that order posts:

function my_date_terms_posts_sort( Array $posts, $order="DESC" ) {
  if ( ! empty( $posts ) ) {
    usort( $posts, function( WP_Post $a, WP_Post $b ) use ( $order ) {
      $at = (int) mysql2date( 'U', $a->post_date );
      $bt = (int) mysql2date( 'U', $b->post_date );
      $orders = strtoupper($order) === 'ASC' ? array( 1, -1 ) : array( -1, 1 );
      return $at === $bt ? 0 : ( $at > $bt ) ? $orders[0] : $orders[1];
    } );
  }
  return $posts;
}

Then the function that get non cached results:

function my_fresh_terms_get_posts( $args, $terms, $tax_query_args = NULL ) {
  $posts = array();
  // we need to know at least the taxonomy
  if ( ! is_array( $tax_query_args ) || ! isset( $tax_query_args['taxonomy'] ) ) return;
  // handle base tax_query
  $base_tax_query = isset( $args['tax_query'] ) ? $args['tax_query'] : array();
  // run a query for each term
  foreach ( $terms as $term ) {
    $term_tax_query = wp_parse_args( array(
      'terms' => array( $term ),
      'field' => is_numeric( $term ) ? 'term_id' : 'slug'
    ), $tax_query_args );
    $args['tax_query'] = array_merge( $base_tax_query, array($term_tax_query) );
    $q = new WP_Query( $args ); 
    if ( $q->have_posts() ) {
      // merging retrieved posts in $posts array
      // preventing duplicates using ID as array keys
      $ids = wp_list_pluck( $q->posts, 'ID' );
      $keyed = array_combine( $ids, array_values( $q->posts ) );
      $posts += $keyed;
    }
  }
  return $posts;
}

Now the function that check cache and return it if available or return non cached results

function my_terms_get_posts( $args, $terms, $tax_query_args = NULL, $order="DESC" ) {
  // we need to know at least the taxonomy
  if ( ! is_array( $tax_query_args ) || ! isset( $tax_query_args['taxonomy'] ) ) return;
  $tax = $tax_query_args['taxonomy'];
  // get cached  results
  $cached = get_transient( "my_terms_get_posts_{$tax}" );
  if ( ! empty( $cached ) ) return $cached;
  // no cached  results, get 'fresh' posts
  $posts = my_fresh_terms_get_posts( $args, $terms, $tax_query_args );
  if ( ! empty($posts) ) {
    // order posts and cache them
    $posts = my_date_terms_posts_sort( $posts, $order );
    set_transient( "my_terms_get_posts_{$tax}",  $posts, DAY_IN_SECONDS );
  }
  return $posts;
}

Cache is auto-cleaned daily, however, is possible to invalidate it everytime a new post in on a specific taxonomy is added or updated. That can be done adding a cleaning cache function on 'set_object_terms'

add_action( 'set_object_terms', function( $object_id, $terms, $tt_ids, $taxonomy ) {
  $taxonomies = get_taxonomies( array( 'object_type' => array('post') ), 'names' );
  if ( in_array( $taxonomy, (array) $taxonomies ) ) {
    delete_transient( "my_terms_get_posts_{$taxonomy}" );
  }
}, 10, 4 );

Usage

// the number of post to retrieve for each term
// total of posts retrieved will be equat to this number x the number of terms passed
// to my_terms_get_posts function
$perterm = 5;

// first define general args:
$paged = ( get_query_var('paged') ) ? get_query_var('paged') : 1;
$args = array(
  'posts_per_page' => $perterm,
  'paged' => $paged,
);

// taxonomy args
$base_tax_args = array(
  'taxonomy' => 'category'
);
$terms = array( 9, 11 ); // is also possible to use slugs

// get posts
$posts = my_terms_get_posts( $args, $terms, $base_tax_args );

// loop
if ( ! empty( $posts ) ) {
  foreach ( $posts as $_post ) {
    global $post;
    setup_postdata( $_post );
    //-------------------------> loop code goes here    
  }
  wp_reset_postdata();
}

Functions are flexible enough to use complex queries, even queries for additional taxonomies:

E.G.

$args = array(
  'posts_per_page' => $perterm,
  'paged' => $paged,
  'post_status' => 'publish',
  'tax_query' => array(
     'relation' => 'AND',
     array(
       'taxonomy' => 'another_taxonomy',
       'terms' => array( 'foo', 'bar' ),
       'field' => 'id'
     )
   )
);

$base_tax_args = array(
  'taxonomy' => 'category',
  'include_children' => FALSE
);

$terms = array( 'a-category', 'another-one' );

$posts = my_terms_get_posts( $args, $terms, $base_tax_args );

In this way the ‘tax_query’ set in $args array will be merged dinamically with the tax query argument in $base_tax_args, for each of the terms in the $terms array.

Is also possible order posts in ascending order:

$posts = my_terms_get_posts( $args, $terms, $base_tax_args, 'ASC' );

Please note:

  1. IMPORTANT: If some posts belong to more than one category among the ones passed to function (e.g. in OP case some posts have both category 9 and 11) the number of post retrieved will be not the one expected, because function will return that posts once
  2. Code needs PHP 5.3+

Leave a Comment