* Admin\API\Reports\DataStore class file.
namespace Automattic\WooCommerce\Admin\API\Reports;
if ( ! defined( 'ABSPATH' ) ) {
use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
* Common parent for custom report data stores.
* We use Report DataStores to separate DB data retrieval logic from the REST API controllers.
* Handles caching, data normalization, intervals-related methods, and other common functionality.
* So, in your custom report DataStore class that extends this class
* you can focus on specifics by overriding the `get_noncached_data` method.
* <pre><code class="language-php">class MyDataStore extends DataStore implements DataStoreInterface {
* /** Cache identifier, used by the `DataStore` class to handle caching for you. */
* protected $cache_key = 'my_thing';
* /** Data store context used to pass to filters. */
* protected $context = 'my_thing';
* /** Table used to get the data. */
* protected static $table_name = 'my_table';
* * Method that overrides the `DataStore::get_noncached_data()` to return the report data.
* * Will be called by `get_data` if there is no data in cache.
* public function get_noncached_data( $query ) {
* // Then return your data in conforming object structure.
* 'data' => $product_data,
* Please use the `woocommerce_data_stores` filter to add your custom data store to the list of available ones.
* Then, your store could be accessed by Controller classes ({@see GenericController::get_datastore_data() GenericController::get_datastore_data()})
* or using {@link \WC_Data_Store::load() \WC_Data_Store::load()}.
* We recommend registering using the REST base name of your Controller as the key, e.g.:
* <pre><code class="language-php">add_filter( 'woocommerce_data_stores', function( $stores ) {
* $stores['reports/my-thing'] = 'MyExtension\Admin\Analytics\Rest_API\MyDataStore';
* This way, `GenericController` will pick it up automatically.
* Note that this class is NOT {@link https://developer.woocommerce.com/docs/how-to-manage-woocommerce-data-stores/ a CRUD data store}.
* It does not implement the {@see WC_Object_Data_Store_Interface WC_Object_Data_Store_Interface} nor extend WC_Data & WC_Data_Store_WP classes.
class DataStore extends SqlQuery implements DataStoreInterface {
* Cache group for the reports.
protected $cache_group = 'reports';
* Time out for the cache.
protected $cache_timeout = 3600;
protected $cache_key = '';
* Table used as a data store for this report.
protected static $table_name = '';
protected $date_column_name = 'date_created';
* Mapping columns to data type to return correct response types.
protected $column_types = array();
* SQL columns to select in the db query.
protected $report_columns = array();
// @todo This does not really belong here, maybe factor out the comparison as separate class?
* Order by property, used in the cmp function.
* Order property, used in the cmp function.
* Query limit parameters.
private $limit_parameters = array();
* Data store context used to pass to filters.
protected $context = 'reports';
* Subquery object for query nesting.
* Intervals query object.
protected $interval_query;
* Refresh the cache for the current query when true.
protected $force_cache_refresh = false;
* Include debugging information in the returned data when true.
protected $debug_cache = true;
* Debugging information to include in the returned data.
protected $debug_cache_data = array();
* @override SqlQuery::__construct()
public function __construct() {
self::set_db_table_name();
$this->assign_report_columns();
if ( $this->report_columns ) {
$this->report_columns = apply_filters(
'woocommerce_admin_report_columns',
self::get_db_table_name()
// Utilize enveloped responses to include debugging info.
// See https://querymonitor.com/blog/2021/05/debugging-wordpress-rest-api-requests/
if ( isset( $_GET['_envelope'] ) ) {
$this->debug_cache = true;
add_filter( 'rest_envelope_response', array( $this, 'add_debug_cache_to_envelope' ), 999, 2 );
* Get the data based on args.
* Returns the report data based on parameters supplied by the user.
* Fetches it from cache or returns `get_noncached_data` result.
* @param array $query_args Query parameters.
* @return stdClass|WP_Error
public function get_data( $query_args ) {
$defaults = $this->get_default_query_vars();
$query_args = wp_parse_args( $query_args, $defaults );
$this->normalize_timezones( $query_args, $defaults );
* We need to get the cache key here because
* parent::update_intervals_sql_params() modifies $query_args.
$cache_key = $this->get_cache_key( $query_args );
$data = $this->get_cached_data( $cache_key );
$data = $this->get_noncached_data( $query_args );
$this->set_cached_data( $cache_key, $data );
* Get the default query arguments to be used by get_data().
* These defaults are only partially applied when used via REST API, as that has its own defaults.
* @return array Query parameters.
public function get_default_query_vars() {
'per_page' => get_option( 'posts_per_page' ),
'before' => TimeInterval::default_before(),
'after' => TimeInterval::default_after(),
* Get table name from database class.
public static function get_db_table_name() {
return isset( $wpdb->{static::$table_name} ) ? $wpdb->{static::$table_name} : $wpdb->prefix . static::$table_name;
* Returns the report data based on normalized parameters.
* Will be called by `get_data` if there is no data in cache.
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error.
public function get_noncached_data( $query_args ) {
/* translators: %s: Method name */
return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) );
* Set table name from database class.
protected static function set_db_table_name() {
if ( static::$table_name && ! isset( $wpdb->{static::$table_name} ) ) {
$wpdb->{static::$table_name} = $wpdb->prefix . static::$table_name;
* Whether or not the report should use the caching layer.
* Provides an opportunity for plugins to prevent reports from using cache.
* @return boolean Whether or not to utilize caching.
protected function should_use_cache() {
* Determines if a report will utilize caching.
* @param bool $use_cache Whether or not to use cache.
* @param string $cache_key The report's cache key. Used to identify the report.
return (bool) apply_filters( 'woocommerce_analytics_report_should_use_cache', true, $this->cache_key );
* Returns string to be used as cache key for the data.
* @param array $params Query parameters.
protected function get_cache_key( $params ) {
if ( isset( $params['force_cache_refresh'] ) ) {
if ( true === $params['force_cache_refresh'] ) {
$this->force_cache_refresh = true;
// We don't want this param in the key.
unset( $params['force_cache_refresh'] );
if ( true === $this->debug_cache ) {
$this->debug_cache_data['query_args'] = $params;
md5( wp_json_encode( $params ) ),
* Wrapper around Cache::get().
* @param string $cache_key Cache key.
protected function get_cached_data( $cache_key ) {
if ( true === $this->debug_cache ) {
$this->debug_cache_data['should_use_cache'] = $this->should_use_cache();
$this->debug_cache_data['force_cache_refresh'] = $this->force_cache_refresh;
$this->debug_cache_data['cache_hit'] = false;
if ( $this->should_use_cache() && false === $this->force_cache_refresh ) {
$cached_data = Cache::get( $cache_key );
$cache_hit = false !== $cached_data;
if ( true === $this->debug_cache ) {
$this->debug_cache_data['cache_hit'] = $cache_hit;
// Cached item has now functionally been refreshed. Reset the option.
$this->force_cache_refresh = false;
* Wrapper around Cache::set().
* @param string $cache_key Cache key.
* @param mixed $value New value.
protected function set_cached_data( $cache_key, $value ) {
if ( $this->should_use_cache() ) {
return Cache::set( $cache_key, $value );
* Add cache debugging information to an enveloped API response.
* @param \WP_REST_Response $response
public function add_debug_cache_to_envelope( $envelope, $response ) {
if ( 0 !== strncmp( '/wc-analytics', $response->get_matched_route(), 13 ) ) {
if ( ! empty( $this->debug_cache_data ) ) {
$envelope['debug_cache'] = $this->debug_cache_data;
* Compares two report data objects by pre-defined object property and ASC/DESC ordering.
* @param stdClass $a Object a.
* @param stdClass $b Object b.
private function interval_cmp( $a, $b ) {
if ( '' === $this->order_by || '' === $this->order ) {
// @todo Should return WP_Error here perhaps?
if ( $a[ $this->order_by ] === $b[ $this->order_by ] ) {
// As relative order is undefined in case of equality in usort, second-level sorting by date needs to be enforced
// so that paging is stable.
if ( $a['time_interval'] === $b['time_interval'] ) {
return 0; // This should never happen.
} elseif ( $a['time_interval'] > $b['time_interval'] ) {
} elseif ( $a['time_interval'] < $b['time_interval'] ) {
} elseif ( $a[ $this->order_by ] > $b[ $this->order_by ] ) {
return strtolower( $this->order ) === 'desc' ? -1 : 1;
} elseif ( $a[ $this->order_by ] < $b[ $this->order_by ] ) {
return strtolower( $this->order ) === 'desc' ? 1 : -1;
* Sorts intervals according to user's request.
* They are pre-sorted in SQL, but after adding gaps, they need to be sorted including the added ones.
* @param stdClass $data Data object, must contain an array under $data->intervals.
* @param string $sort_by Ordering property.
* @param string $direction DESC/ASC.
protected function sort_intervals( &$data, $sort_by, $direction ) {
$this->sort_array( $data->intervals, $sort_by, $direction );
* Sorts array of arrays based on subarray key $sort_by.
* @param array $arr Array to sort.
* @param string $sort_by Ordering property.
* @param string $direction DESC/ASC.
protected function sort_array( &$arr, $sort_by, $direction ) {
$this->order_by = $this->normalize_order_by( $sort_by );
$this->order = $direction;
usort( $arr, array( $this, 'interval_cmp' ) );
* Fills in interval gaps from DB with 0-filled objects.
* @param array $db_intervals Array of all intervals present in the db.
* @param DateTime $start_datetime Start date.
* @param DateTime $end_datetime End date.
* @param string $time_interval Time interval, e.g. day, week, month.
* @param stdClass $data Data with SQL extracted intervals.
protected function fill_in_missing_intervals( $db_intervals, $start_datetime, $end_datetime, $time_interval, &$data ) {
// @todo This is ugly and messy.
$local_tz = new \DateTimeZone( wc_timezone_string() );
// At this point, we don't know when we can stop iterating, as the ordering can be based on any value.
$time_ids = array_flip( wp_list_pluck( $data->intervals, 'time_interval' ) );
$db_intervals = array_flip( $db_intervals );
// Totals object used to get all needed properties.
$totals_arr = get_object_vars( $data->totals );
foreach ( $totals_arr as $key => $val ) {
// @todo Should 'products' be in intervals?
unset( $totals_arr['products'] );
while ( $start_datetime <= $end_datetime ) {
$next_start = TimeInterval::iterate( $start_datetime, $time_interval );
$time_id = TimeInterval::time_interval_id( $time_interval, $start_datetime );
// Either create fill-zero interval or use data from db.
if ( $next_start > $end_datetime ) {
$interval_end = $end_datetime->format( 'Y-m-d H:i:s' );
$prev_end_timestamp = (int) $next_start->format( 'U' ) - 1;
$prev_end = new \DateTime();
$prev_end->setTimestamp( $prev_end_timestamp );
$prev_end->setTimezone( $local_tz );
$interval_end = $prev_end->format( 'Y-m-d H:i:s' );
if ( array_key_exists( $time_id, $time_ids ) ) {
// For interval present in the db for this time frame, just fill in dates.
$record = &$data->intervals[ $time_ids[ $time_id ] ];
$record['date_start'] = $start_datetime->format( 'Y-m-d H:i:s' );
$record['date_end'] = $interval_end;