| Server IP : 216.238.66.20 / Your IP : 216.73.216.199 Web Server : nginx/1.30.4 System : Linux woropds 5.15.0-187-generic #197-Ubuntu SMP Fri Jul 17 19:17:01 UTC 2026 x86_64 User : root ( 0) PHP Version : 8.2.33 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /var/www/sweetheart.mx/htdocs/wp-content/plugins/wp-slimstat/src/Utils/ |
Upload File : |
<?php
namespace SlimStat\Utils;
use InvalidArgumentException;
class Query
{
private $queries = [];
private $operation;
private $table;
private $fields = '*';
private $orderClause;
private $groupByClause;
private $havingClauses = [];
private $limitClause;
private $whereRelation = 'AND';
private $setClauses = [];
private $setValuesToPrepare = [];
private $joinClauses = [];
private $whereClauses = [];
private $rawWhereClause = [];
private $valuesToPrepare = [];
private $insertValues = [];
private $ignore = false;
private $allowCaching = false;
private $cacheExpiration = 3600;
protected $db;
private $_isLiveQuery = false;
private static $processingTimestamp = null;
/**
* Constructor.
*
* Initializes the query with the global $wpdb instance.
*/
public function __construct()
{
$this->db = \wp_slimstat::$wpdb ?? $GLOBALS['wpdb'];
}
/**
* Initializes a new query instance for a select operation on a table.
*
* @param string|array $fields The fields to select. If an array is provided, the fields are
* concatenated with a comma separator and the resulting string
* is used as the SELECT clause.
*
* @return static A new Query instance configured for a select operation.
*/
public static function select($fields = '*')
{
$instance = new self();
$instance->operation = 'select';
$instance->fields = is_array($fields) ? implode(', ', $fields) : $fields;
return $instance;
}
/**
* Initializes a new query instance for an update operation on the specified table.
*
* @param string $table The name of the table to update.
*
* @return static A new Query instance configured for an update operation.
*/
public static function update($table)
{
$instance = new self();
$instance->operation = 'update';
$instance->table = $table;
return $instance;
}
/**
* Initializes a new query instance for a delete operation on the specified table.
*
* @param string $table The name of the table to delete from.
*
* @return static
*/
public static function delete($table)
{
$instance = new self();
$instance->operation = 'delete';
$instance->table = $table;
return $instance;
}
/**
* Initializes a new query instance for an insert operation on the specified table.
*
* @param string $table The name of the table to insert data into.
*
* @return self A new Query instance configured for an insert operation.
*/
public static function insert($table)
{
$instance = new self();
$instance->operation = 'insert';
$instance->table = $table;
return $instance;
}
/**
* Adds IGNORE to the query.
*
* @param bool $ignore
* @return $this
*/
public function ignore($ignore = true)
{
$this->ignore = $ignore;
return $this;
}
/**
* Combines multiple query instances into a single UNION query.
*
* @param array $queries An array of Query instances to be united.
*
* @return self A new Query instance representing the UNION of the provided queries.
*/
public static function union($queries)
{
$instance = new self();
$instance->operation = 'union';
$instance->queries = $queries;
return $instance;
}
/**
* Specifies the table to be used in the query.
*
* @param string $table The name of the table to use.
*
* @return $this
*/
public function from($table)
{
$this->table = $table;
return $this;
}
/**
* Sets the values for an insert operation.
*
* @return $this
*/
public function values(array $values)
{
if ($values === []) {
return $this;
}
// Check if it's an array of arrays for bulk insert
if (isset($values[0]) && is_array($values[0])) {
// Bulk insert
$this->insertValues = $values;
} else {
// Single row insert
$this->insertValues[] = $values;
}
return $this;
}
/**
* Sets the values for the columns in the current query.
*
* This function prepares the column assignments for an SQL update operation.
* It supports string, numeric, and null values, and automatically escapes
* field names to prevent SQL injection.
*
* @param array $values An associative array of column-value pairs to set.
* The array key is the column name, and the value is
* the value to assign to the column.
*
* @return $this
*/
public function set($values)
{
if (empty($values)) {
return $this;
}
foreach ($values as $field => $value) {
$column = '`' . str_replace('`', '``', $field) . '`';
if (is_string($value)) {
$this->setClauses[] = sprintf('%s = %%s', $column);
$this->setValuesToPrepare[] = $value;
} elseif (is_numeric($value)) {
$this->setClauses[] = sprintf('%s = %%s', $column);
$this->setValuesToPrepare[] = $value;
} elseif (is_null($value)) {
$this->setClauses[] = $column . ' = NULL';
}
}
return $this;
}
/**
* Sets a raw value for a column, allowing for SQL expressions.
*
* @param string $column
* @param string $expression
* @param array $params
* @return $this
*/
public function setRaw($column, $expression, $params = [])
{
$this->setClauses[] = sprintf('`%s` = %s', str_replace('`', '``', $column), $expression);
if (!empty($params)) {
$this->setValuesToPrepare = array_merge($this->setValuesToPrepare, $params);
}
return $this;
}
/**
* Add a WHERE clause to the query.
*
* @param string $field The field to filter on.
* @param string $operator The operator to use. Supported operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN.
* @param mixed $value The value to filter on. Can be a string, int, array or null.
*
* @return $this
*
* @throws InvalidArgumentException If the operator is not supported.
*/
public function where($field, $operator, $value)
{
if ('BETWEEN' === strtoupper($operator) && is_array($value) && 2 === count($value) && (null !== $value[0] && null !== $value[1])) {
$condition = $this->generateCondition($field, $operator, $value);
if (!empty($condition)) {
$this->whereClauses[] = $condition['condition'];
$this->valuesToPrepare = array_merge($this->valuesToPrepare, $condition['values']);
}
return $this;
}
if (is_array($value)) {
$value = array_filter(array_values($value));
}
if (!is_numeric($value) && empty($value)) {
return $this;
}
$condition = $this->generateCondition($field, $operator, $value);
if (!empty($condition)) {
$this->whereClauses[] = $condition['condition'];
$this->valuesToPrepare = array_merge($this->valuesToPrepare, $condition['values']);
}
return $this;
}
/**
* Add a raw WHERE clause to the query. If values are provided, they will be
* escaped and inserted into the query.
*
* @param string $condition The raw WHERE condition.
* @param array $values Values to be inserted into the condition.
*
* @return $this
*/
public function whereRaw($condition, $values = [])
{
$this->rawWhereClause[] = empty($values) ? $condition : $this->prepareQuery($condition, $values);
return $this;
}
/**
* Add a raw HAVING clause to the query. If values are provided, they will be
* escaped and inserted into the clause. Any leading "HAVING" keyword is stripped
* to ensure correct placement in the final query.
*
* @param string $condition The raw HAVING condition.
* @param array $values Values to be inserted into the condition.
*
* @return $this
*/
public function havingRaw($condition, $values = [])
{
// Strip an optional leading HAVING keyword to avoid duplication
$condition = preg_replace('/^\s*HAVING\s+/i', '', $condition);
$this->havingClauses[] = empty($values) ? $condition : $this->prepareQuery($condition, $values);
return $this;
}
/**
* Sets the GROUP BY clause for the query.
*
* @param string|array $fields The fields to group by. Can be a comma-separated string or an array of fields.
*
* @return $this
*/
public function groupBy($fields)
{
if (is_array($fields)) {
$fields = implode(', ', $fields);
}
if (!empty($fields)) {
$this->groupByClause = 'GROUP BY ' . $fields;
}
return $this;
}
/**
* Sets the ORDER BY clause for the query.
*
* @param string|array $fields The fields to order by. Can be a comma-separated string or an array of fields.
* @param string $order The order direction, either 'ASC' or 'DESC'. Defaults to 'DESC'.
*
* @return $this
*/
public function orderBy($fields, $order = 'DESC')
{
if (empty($fields)) {
return $this;
}
if (is_string($fields)) {
if (preg_match('/\b(ASC|DESC)\b/i', $fields)) {
$this->orderClause = 'ORDER BY ' . $fields;
return $this;
}
$fields = explode(',', $fields);
$fields = array_map('trim', $fields);
}
if (is_array($fields)) {
$order = strtoupper($order);
if (!in_array($order, ['ASC', 'DESC'])) {
$order = 'DESC';
}
$orderParts = [];
foreach ($fields as $field) {
$orderParts[] = sprintf('%s %s', $field, $order);
}
$this->orderClause = 'ORDER BY ' . implode(', ', $orderParts);
}
return $this;
}
/**
* Sets the LIMIT clause for the query, with an optional OFFSET.
*
* @param int $limit The maximum number of results to return.
* @param int $offset The number of rows to skip. Defaults to 0.
*
* @return $this
*/
public function limit($limit, $offset = 0)
{
$limit = intval($limit);
$offset = intval($offset);
if ($offset > 0) {
$this->limitClause = sprintf('LIMIT %d OFFSET %d', $limit, $offset);
} else {
$this->limitClause = 'LIMIT ' . $limit;
}
return $this;
}
/**
* Sets the LIMIT and OFFSET clauses for pagination.
*
* @param int $page The page number. Defaults to 1.
* @param int $perPage The number of results to show per page. Defaults to 10.
*
* @return $this
*/
public function perPage($page = 1, $perPage = 10)
{
$page = intval($page);
$perPage = intval($perPage);
if ($page > 0 && $perPage > 0) {
$offset = ($page - 1) * $perPage;
$this->limitClause = sprintf('LIMIT %d OFFSET %d', $perPage, $offset);
}
return $this;
}
/**
* Join another table.
*
* @param string $table The table to join.
* @param string|array $on The join condition. Can be an array with two fields to join on, or a string with a condition.
* @param array $conditions An array of conditions to join on. Each condition is an array with three elements: field, operator, value.
* @param string $joinType The type of join. Can be INNER, LEFT, or RIGHT. Defaults to INNER.
*
* @return $this
*
* @throws InvalidArgumentException If the join condition is invalid.
*/
public function join($table, $on, $conditions = [], $joinType = 'INNER')
{
$joinType = strtoupper($joinType);
if (is_array($on) && 2 == count($on)) {
$joinClause = sprintf('%s JOIN %s ON %s = %s', $joinType, $table, $on[0], $on[1]);
if (!empty($conditions)) {
foreach ($conditions as $condition) {
$field = $condition[0];
$operator = $condition[1];
$value = $condition[2];
$cond = $this->generateCondition($field, $operator, $value);
if (!empty($cond)) {
$joinClause .= ' AND ' . $cond['condition'];
$this->valuesToPrepare = array_merge($this->valuesToPrepare, $cond['values']);
}
}
}
$this->joinClauses[] = $joinClause;
return $this;
}
// Backward compatibility: allow two string fields passed separately
if (is_string($on) && is_string($conditions) && '' !== $on && '' !== $conditions) {
$this->joinClauses[] = sprintf('%s JOIN %s ON %s = %s', $joinType, $table, $on, $conditions);
return $this;
}
// Allow raw ON condition string
if (is_string($on) && '' !== $on && (empty($conditions) || (is_array($conditions) && empty($conditions)))) {
$this->joinClauses[] = sprintf('%s JOIN %s ON %s', $joinType, $table, $on);
return $this;
}
throw new InvalidArgumentException('Invalid join clause');
}
/**
* Set the caching flag and expiration time.
*
* @param bool $flag Whether to allow caching.
* @param int $expiration The cache expiration time in seconds.
*
* @return $this
*/
public function allowCaching($flag = true, $expiration = 3600)
{
$this->allowCaching = $flag;
$this->cacheExpiration = $expiration;
return $this;
}
/**
* Set the caching flag depending on whether the given date range overlaps with today.
*
* If the given date range is entirely in the past, caching is allowed. Otherwise, caching is disabled.
*
* @param int|string $to The end date of the range (Y-m-d or Y-m-d H:i:s or timestamp)
*
* @return $this
*/
public function canUseCacheForDateRange($to)
{
$today = $this->getTodayDate();
$toTs = is_numeric($to) ? intval($to) : strtotime($to);
if ($toTs < $today) {
$this->allowCaching(true, $this->cacheExpiration);
} else {
$this->allowCaching(false);
}
}
/**
* Set the processing timestamp context for caching decisions.
* This should be set to the timestamp of the event being processed (e.g., $stat['dt'])
* to ensure caching decisions are based on the event time, not the current server time.
*
* @param int|null $timestamp Unix timestamp of the event being processed, or null to use current time.
* @return void
*/
public static function setProcessingTimestamp($timestamp)
{
self::$processingTimestamp = $timestamp;
}
/**
* Get the timestamp for the start of today.
* If a processing timestamp has been set, calculates "today" based on that timestamp.
* Otherwise, uses the current server time.
*
* @return int The timestamp for the start of today (midnight).
*/
protected function getTodayDate()
{
if (null !== self::$processingTimestamp) {
return strtotime(date('Y-m-d 00:00:00', self::$processingTimestamp));
}
return strtotime(date('Y-m-d 00:00:00'));
}
protected function getCacheKey($input)
{
$normalized = $input;
if (preg_match('/BETWEEN\s+[\'\"]?(\d{4}-\d{2}-\d{2})[\s\d:]*[\'\"]?\s+AND\s+[\'\"]?(\d{4}-\d{2}-\d{2})[\s\d:]*[\'\"]?/i', $input, $matches)) {
$from = $matches[1];
$to = $matches[2];
$normalized = preg_replace('/BETWEEN\s+[\'\"]?(\d{4}-\d{2}-\d{2})[\s\d:]*[\'\"]?\s+AND\s+[\'\"]?(\d{4}-\d{2}-\d{2})[\s\d:]*[\'\"]?/i', sprintf("BETWEEN '%s' AND '%s'", $from, $to), $input);
}
$normalized = preg_replace_callback('/(\d{4}-\d{2}-\d{2})[\s\d:]{0,8}/', fn ($m) => $m[1], $normalized);
$hash = substr(md5($normalized), 0, 10);
return sprintf('wp_slimstat_cache_%s', $hash);
}
protected function getCachedResult($input)
{
$cacheKey = $this->getCacheKey($input);
return get_transient($cacheKey);
}
protected function setCachedResult($input, $result, $expiration = DAY_IN_SECONDS)
{
$cacheKey = $this->getCacheKey($input);
return set_transient($cacheKey, $result, $expiration);
}
/**
* Analyzes the WHERE clauses to detect date ranges that overlap with today.
*
* This function iterates through the WHERE clauses to find any clause that specifies
* a date range (using "BETWEEN %s AND %s") and determines if this range overlaps
* with today. It extracts the timestamps for the start and end of the historical
* period (up to the start of today) and the live period (starting today).
*
* @return array<int|bool|null> An array containing:
* - boolean: whether a split range was found
* - int|null: historical start timestamp
* - int|null: historical end timestamp (inclusive)
* - int|null: live start timestamp (inclusive)
* - int|null: live end timestamp
*/
protected function getSplitDateRanges2()
{
$dtField = 'dt';
$todayStart = $this->getTodayDate();
foreach ($this->whereClauses as $idx => $clause) {
if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
$from = null;
$to = null;
$dtIdx = 0;
foreach ($this->whereClauses as $i => $c) {
if ($i == $idx) {
break;
}
if (preg_match('/%s/', $c)) {
$dtIdx += substr_count($c, '%s');
}
}
$from = $this->valuesToPrepare[$dtIdx] ?? null;
$to = $this->valuesToPrepare[$dtIdx + 1] ?? null;
$fromTs = is_numeric($from) ? intval($from) : strtotime($from);
$toTs = is_numeric($to) ? intval($to) : strtotime($to);
if (null !== $fromTs && null !== $toTs && $fromTs < $todayStart && $toTs >= $todayStart) {
return [true, $fromTs, $todayStart - 1, $todayStart, $toTs];
}
}
}
return [false, null, null, null, null];
}
/**
* Helper: Generate a SQL condition based on the given field, operator and value.
*
* Supported operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN
*
* @param string $field Field name
* @param string $operator SQL operator
* @param mixed $value Value to be used in the condition. Can be a string, int, array or null.
*
* @return array|false Array with keys 'condition' and 'values', or false if the condition could not be generated.
*
* @throws InvalidArgumentException If the operator is not supported.
*/
protected function generateCondition($field, $operator, $value)
{
$condition = '';
$values = [];
switch ($operator) {
case '=':
case '!=':
case '>':
case '>=':
case '<':
case '<=':
case 'LIKE':
case 'NOT LIKE':
$condition = sprintf('%s %s %%s', $field, $operator);
$values[] = $value;
break;
case 'IS':
case 'IS NOT':
if (is_null($value)) {
$condition = sprintf('%s %s NULL', $field, $operator);
}
break;
case 'IN':
case 'NOT IN':
if (is_string($value)) {
$value = explode(',', $value);
}
if (!empty($value) && is_array($value) && 1 == count($value)) {
$operator = ('IN' === $operator) ? '=' : '!=';
return $this->generateCondition($field, $operator, reset($value));
}
if (!empty($value) && is_array($value)) {
$placeholders = implode(', ', array_fill(0, count($value), '%s'));
$condition = sprintf('%s %s (%s)', $field, $operator, $placeholders);
$values = $value;
}
break;
case 'BETWEEN':
if (is_array($value) && 2 === count($value)) {
$condition = sprintf('%s BETWEEN %%s AND %%s', $field);
$values = $value;
}
break;
default:
throw new InvalidArgumentException('Unsupported operator: ' . $operator);
}
if ('' === $condition || '0' === $condition) {
return null;
}
return [
'condition' => $condition,
'values' => $values,
];
}
/**
* Builds and returns an SQL query string based on the current operation and clauses.
*
* This function constructs a SQL query by assembling various parts such as the
* operation type (select, update, delete, insert, union), join clauses, where
* clauses, group by, order by, and limit clauses. It supports conditional logic
* to append appropriate SQL syntax based on the operation and provided clauses.
*
* @return string The constructed SQL query string.
*
* @throws InvalidArgumentException If the operation type is unknown.
*/
protected function buildQuery()
{
switch ($this->operation) {
case 'select':
$query = sprintf('SELECT %s FROM %s', $this->fields, $this->table);
break;
case 'update':
$operation = $this->ignore ? 'UPDATE IGNORE' : 'UPDATE';
$query = sprintf('%s %s SET ', $operation, $this->table) . implode(', ', $this->setClauses);
break;
case 'delete':
$query = 'DELETE FROM ' . $this->table;
break;
case 'insert':
if (empty($this->insertValues)) {
return '';
}
$operation = $this->ignore ? 'INSERT IGNORE INTO' : 'INSERT INTO';
$sampleRow = $this->insertValues[0];
$keys = array_keys($sampleRow);
$query = sprintf('%s %s (`%s`) VALUES ', $operation, $this->table, implode('`, `', $keys));
$valueSets = [];
foreach ($this->insertValues as $row) {
$placeholders = implode(', ', array_fill(0, count($row), '%s'));
$valueSets[] = '(' . $placeholders . ')';
foreach ($row as $value) {
$this->valuesToPrepare[] = $value;
}
}
$query .= implode(', ', $valueSets);
break;
case 'union':
$query = implode(' UNION ', $this->queries);
break;
default:
throw new InvalidArgumentException('Unknown operation');
}
if (!empty($this->joinClauses)) {
$query .= ' ' . implode(' ', $this->joinClauses);
}
if (!empty($this->whereClauses)) {
$query .= ' WHERE ' . implode(sprintf(' %s ', $this->whereRelation), $this->whereClauses);
}
if (!empty($this->rawWhereClause)) {
$wrappedClauses = array_map(fn($clause) => "($clause)", $this->rawWhereClause);
if (!empty($this->whereClauses)) {
$query .= ' AND ' . implode(' AND ', $wrappedClauses);
} else {
$query .= ' WHERE ' . implode(' AND ', $wrappedClauses);
}
}
if (!empty($this->groupByClause)) {
$query .= ' ' . $this->groupByClause;
}
if (!empty($this->havingClauses)) {
$query .= ' HAVING ' . implode(' AND ', $this->havingClauses);
}
if (!empty($this->orderClause)) {
$query .= ' ' . $this->orderClause;
}
if (!empty($this->limitClause)) {
$query .= ' ' . $this->limitClause;
}
return $query;
}
/**
* Prepares a query for execution by replacing placeholders with actual values.
* Supported placeholders are %i, %s, %f, and %d.
* If the query contains more than one placeholder, the $args parameter should be an array with the same number of elements.
* If the query contains only one placeholder, the $args parameter can be either an array or a single value.
* If the query contains no placeholders, the $args parameter is ignored.
*
* @param string $query
* @param array $args
*
* @return string The prepared query
*/
protected function prepareQuery($query, $args = [])
{
if (preg_match('/%[i|s|f|d]/', $query)) {
$placeholder_count = preg_match_all('/%[i|s|f|d]/', $query, $matches);
$args_count = is_array($args) ? count($args) : (empty($args) ? 0 : 1);
if (1 === $placeholder_count) {
$query = is_array($args) ? $this->db->prepare($query, reset($args)) : $this->db->prepare($query, $args);
} elseif (is_array($args) && $args_count === $placeholder_count) {
$query = $this->db->prepare($query, $args);
} else {
return $query;
}
}
return $query;
}
/**
* Generates a cache key for a given query and its arguments.
*
* This method serializes the query and arguments into a data array,
* creates an MD5 hash of the serialized data, and returns a truncated
* hash as a unique cache key prefixed with 'wp_slimstat_query_'.
*
* @param string $query The SQL query.
* @param array $args The query arguments.
*
* @return string The generated cache key.
*/
protected function getCacheKeyForQuery($query, $args = [])
{
$data = [
'query' => $query,
'args' => $args,
];
$hash = substr(md5(serialize($data)), 0, 16);
return 'wp_slimstat_query_' . $hash;
}
/**
* Retrieves the cached result for the given query and args
*
* @param string $query The SQL query
* @param array $args The query arguments
*
* @return mixed The query result, or false if there is no cached result
*/
protected function getCachedResultForQuery($query, $args = [])
{
$cacheKey = $this->getCacheKeyForQuery($query, $args);
$data = get_transient($cacheKey);
if (false === $data) {
return false;
}
if (is_array($data) && isset($data['chunks']) && isset($data['size'])) {
$chunks = [];
for ($i = 0; $i < $data['chunks']; $i++) {
$chunk = get_transient($cacheKey . '_' . $i);
if (false === $chunk) {
return false;
}
$chunks[] = $chunk;
}
$data = implode('', $chunks);
} elseif (is_array($data)) {
// Data is already an array (from transient), return directly
return $data;
}
if (function_exists('gzuncompress') && is_string($data)) {
$first2 = substr($data, 0, 2);
if ("\x1f\x8b" === $first2 || "\x78\x9c" === $first2 || "\x78\xda" === $first2) {
$data = @gzuncompress($data);
}
}
// Use JSON decode instead of unserialize for security
$decoded = json_decode($data, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
// Return false if JSON decode failed (corrupted or legacy data)
return false;
}
/**
* Sets the transient cache for the given query and args
*
* @param string $query The SQL query
* @param array $args The query arguments
* @param mixed $result The query result
* @param int $expiration The cache expiration time, in seconds
*
* @return bool True if cache was successfully set, false otherwise
*/
protected function setCachedResultForQuery($query, $args, $result, $expiration = 300)
{
$cacheKey = $this->getCacheKeyForQuery($query, $args);
// Use JSON encode instead of serialize for security
$data = wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (false === $data) {
return false;
}
$max_chunk_size = 900 * 1024; // 900KB
$old_meta = get_transient($cacheKey);
if (is_array($old_meta) && isset($old_meta['chunks'])) {
for ($i = 0; $i < $old_meta['chunks']; $i++) {
delete_transient($cacheKey . '_' . $i);
}
}
if (strlen($data) > $max_chunk_size) {
$chunks = str_split($data, $max_chunk_size);
$meta = [
'chunks' => count($chunks),
'size' => strlen($data),
];
if (strlen(wp_json_encode($meta)) > $max_chunk_size) {
return false;
}
set_transient($cacheKey, $meta, $expiration);
foreach ($chunks as $i => $chunk) {
set_transient($cacheKey . '_' . $i, $chunk, $expiration);
}
} else {
set_transient($cacheKey, $data, $expiration);
}
return true;
}
/**
* Extracts a date range from the WHERE clause where the range overlaps with today.
* Returns an array with the following elements:
* - boolean: whether a split range was found
* - int: historical start timestamp
* - int: historical end timestamp (inclusive)
* - int: live start timestamp (inclusive)
* - int: live end timestamp
* - int: index of the date field in the WHERE clause
* - int: index of the WHERE clause with the date range
*
* @return array<int, int, int, int, int, int, int>
*/
protected function getSplitDateRanges()
{
$dtField = 'dt';
$todayStart = $this->getTodayDate();
foreach ($this->whereClauses as $idx => $clause) {
if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
$dtIdx = 0;
foreach ($this->whereClauses as $i => $c) {
if ($i == $idx) {
break;
}
if (preg_match('/%s/', $c)) {
$dtIdx += substr_count($c, '%s');
}
}
$from = $this->valuesToPrepare[$dtIdx] ?? null;
$to = $this->valuesToPrepare[$dtIdx + 1] ?? null;
$fromTs = is_numeric($from) ? intval($from) : strtotime($from);
$toTs = is_numeric($to) ? intval($to) : strtotime($to);
if (null !== $fromTs && null !== $toTs && $fromTs < $todayStart && $toTs >= $todayStart) {
return [true, $fromTs, $todayStart - 1, $todayStart, $toTs, $dtIdx, $idx];
}
}
}
return [false, null, null, null, null, null, null];
}
/**
* Merges two arrays of result rows from the historical and live parts of a query.
* If $groupKey is set, the function will group the results by this key and sum the
* values in $sumFields for each group. Otherwise, the function will return the
* array merge of the two arrays.
*
* @param array $historical The result rows from the historical part of the query
* @param array $live The result rows from the live part of the query
* @param string $groupKey The key to group the results by
* @param array $sumFields The fields to sum for each group
*
* @return array The merged and grouped result rows
*/
protected function mergeGroupResults($historical, $live, $groupKey = null, $sumFields = ['counthits'])
{
$historical = is_array($historical) ? $historical : [];
$live = is_array($live) ? $live : [];
// If no group key provided, try to determine it from the data
if (!$groupKey) {
// Try to find a suitable group key from the first row
$firstRow = !empty($historical) ? $historical[0] : (!empty($live) ? $live[0] : null);
if ($firstRow && is_array($firstRow)) {
// Use the first column that's not a sum field
foreach (array_keys($firstRow) as $key) {
if (!in_array($key, $sumFields)) {
$groupKey = $key;
break;
}
}
}
// If still no group key, just merge without grouping
if (!$groupKey) {
return array_merge($historical, $live);
}
}
$result = [];
foreach ($historical as $row) {
if (isset($row[$groupKey])) {
$key = $row[$groupKey];
$result[$key] = $row;
}
}
foreach ($live as $row) {
if (isset($row[$groupKey])) {
$key = $row[$groupKey];
if (isset($result[$key])) {
foreach ($sumFields as $field) {
if (isset($row[$field])) {
$result[$key][$field] += $row[$field];
}
}
} else {
$result[$key] = $row;
}
}
}
return array_values($result);
}
/**
* Re-sort merged results to honour the original ORDER BY clause.
*
* After mergeGroupResults() sums counts from historical + live partitions,
* the relative ordering may no longer match the SQL ORDER BY (e.g. a row
* whose counthits grew after summing should move up). This method parses
* the stored orderClause and applies an equivalent PHP usort().
*
* Only fields that exist as keys in the result rows are used for sorting;
* SQL expressions (MAX(dt), REPLACE(…), etc.) are silently skipped because
* they don't appear as array keys after $wpdb->get_results().
*
* @param array $results Merged result rows (associative arrays).
*
* @return array Re-sorted rows.
*/
protected function sortMergedResults(array $results): array
{
if (empty($this->orderClause) || empty($results)) {
return $results;
}
// Strip "ORDER BY " prefix → "counthits DESC, resource ASC"
$orderStr = preg_replace('/^ORDER\s+BY\s+/i', '', $this->orderClause);
$parts = array_map('trim', explode(',', $orderStr));
// Determine which fields actually exist in the result rows.
$availableKeys = array_keys($results[0]);
$sortFields = [];
foreach ($parts as $part) {
if (preg_match('/^(.+?)\s+(ASC|DESC)$/i', $part, $m)) {
$field = $m[1];
$dir = strtoupper($m[2]);
// Resolve SQL aggregate functions to their result-set alias.
// e.g. MAX(dt) → dt (from "MAX(dt) AS dt" in the SELECT).
$resolvedField = $field;
if (!in_array($field, $availableKeys, true) && preg_match('/^(?:MAX|MIN|COUNT|SUM|AVG)\((\w+)\)$/i', $field, $aggMatch)) {
$resolvedField = $aggMatch[1];
}
if (in_array($resolvedField, $availableKeys, true)) {
$sortFields[] = ['field' => $resolvedField, 'dir' => $dir];
}
}
}
if (empty($sortFields)) {
return $results;
}
usort($results, static function ($a, $b) use ($sortFields) {
foreach ($sortFields as $sf) {
$field = $sf['field'];
$valA = $a[$field] ?? null;
$valB = $b[$field] ?? null;
// NULLs sort last regardless of direction.
if (null === $valA && null === $valB) {
continue;
}
if (null === $valA) {
return 1;
}
if (null === $valB) {
return -1;
}
if (is_numeric($valA) && is_numeric($valB)) {
$cmp = ((float) $valA <=> (float) $valB);
} else {
$cmp = strcmp((string) $valA, (string) $valB);
}
if (0 !== $cmp) {
return ('DESC' === $sf['dir']) ? -$cmp : $cmp;
}
}
return 0;
});
return $results;
}
/**
* Helper: Extract date ranges from WHERE clauses
*
* @return array Array of extracted date ranges with keys from, to, clauseIdx, and valueIdx
*/
protected function extractDateRangesFromWhere()
{
$dtField = 'dt';
$ranges = [];
$dtIdx = 0;
foreach ($this->whereClauses as $idx => $clause) {
if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
$from = $this->valuesToPrepare[$dtIdx] ?? null;
$to = $this->valuesToPrepare[$dtIdx + 1] ?? null;
$ranges[] = [
'from' => $from,
'to' => $to,
'clauseIdx' => $idx,
'valueIdx' => $dtIdx,
];
}
if (preg_match_all('/%s/', $clause, $m)) {
$dtIdx += count($m[0]);
}
}
return $ranges;
}
/**
* Helper: Process a date range query, splitting it into historical and live parts as needed.
*
* @param int|string $from Start date (Y-m-d or Y-m-d H:i:s or timestamp)
* @param int|string $to End date (Y-m-d or Y-m-d H:i:s or timestamp)
* @param array $baseWhereClauses where clauses to use for the query
* @param array $baseValuesToPrepare values to prepare for the query
*
* @return array result set
*/
protected function processDateRange($from, $to, $baseWhereClauses, $baseValuesToPrepare)
{
$todayStart = $this->getTodayDate();
$fromTs = is_numeric($from) ? intval($from) : strtotime($from);
$toTs = is_numeric($to) ? intval($to) : strtotime($to);
if ($fromTs >= $todayStart) {
$liveQuery = clone $this;
$liveQuery->whereClauses = $baseWhereClauses;
$liveQuery->valuesToPrepare = $baseValuesToPrepare;
$liveQuery->whereDate('dt', ['from' => $fromTs, 'to' => $toTs], true);
$liveQuery->allowCaching(false, 0);
return $liveQuery->getAll();
}
if ($toTs < $todayStart) {
$cacheQuery = clone $this;
$cacheQuery->whereClauses = $baseWhereClauses;
$cacheQuery->valuesToPrepare = $baseValuesToPrepare;
$cacheQuery->whereDate('dt', ['from' => $fromTs, 'to' => $toTs]);
$cacheQuery->allowCaching(true, $this->cacheExpiration);
return $cacheQuery->getAll();
}
$histQuery = clone $this;
$histQuery->whereClauses = $baseWhereClauses;
$histQuery->valuesToPrepare = $baseValuesToPrepare;
$histQuery->whereDate('dt', ['from' => $fromTs, 'to' => $todayStart - 1]);
$histQuery->allowCaching(true, $this->cacheExpiration);
$historical = $histQuery->getAll();
$liveQuery = clone $this;
$liveQuery->whereClauses = $baseWhereClauses;
$liveQuery->valuesToPrepare = $baseValuesToPrepare;
$liveQuery->whereDate('dt', ['from' => $todayStart, 'to' => $toTs], true);
$liveQuery->allowCaching(false, 0);
$live = $liveQuery->getAll();
if ($toTs == $todayStart) {
return $historical;
}
if ($todayStart - 1 < $fromTs) {
return $live;
}
return array_merge($historical, $live);
}
/**
* Execute the query and return a single value from the first row
*
* This is a shortcut for `getAll()[0][0]`
*
* @return mixed The value, or false/null if no rows are returned
*/
public function getVar()
{
// When caching is enabled and the date range includes today, skip cache
// to stay consistent with getAll() which always fetches fresh live data.
// Without this, cached scalar values (e.g. $pageviews) become stale while
// getAll() returns fresh grouped data, causing percentage calculations >100%.
$useCache = $this->allowCaching;
if ($useCache) {
[$split] = $this->getSplitDateRanges();
if ($split) {
$useCache = false;
}
}
$query = $this->buildQuery();
$query = $this->prepareQuery($query, $this->valuesToPrepare);
if ($useCache) {
$cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
if (false !== $cachedResult) {
return $cachedResult;
}
}
$result = $this->db->get_var($query);
if ($useCache) {
$this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
}
return $result;
}
/**
* Execute the query and return a single row
*
* This is a shortcut for `getAll()[0]`
*
* @return array The row, or false/null if no rows are returned
*/
public function getRow()
{
$useCache = $this->allowCaching;
if ($useCache) {
[$split] = $this->getSplitDateRanges();
if ($split) {
$useCache = false;
}
}
$query = $this->buildQuery();
$query = $this->prepareQuery($query, $this->valuesToPrepare);
if ($useCache) {
$cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
if (false !== $cachedResult) {
return $cachedResult;
}
}
$result = $this->db->get_row($query);
if ($useCache) {
$this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
}
return $result;
}
/**
* Execute the query and return a single column
*
* This is a shortcut for `getAll()`
*
* @return array The column, or false/null if no columns are returned
*/
public function getCol()
{
$useCache = $this->allowCaching;
if ($useCache) {
[$split] = $this->getSplitDateRanges();
if ($split) {
$useCache = false;
}
}
$query = $this->buildQuery();
$query = $this->prepareQuery($query, $this->valuesToPrepare);
if ($useCache) {
$cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
if (false !== $cachedResult) {
return $cachedResult;
}
}
$result = $this->db->get_col($query);
if ($useCache) {
$this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
}
return $result;
}
/**
* Check if a where clause for a field/operator exists (e.g. 'dt BETWEEN').
*
* @param string $field
* @param string|null $operator
*
* @return bool
*/
public function hasWhereClause(string $field, ?string $operator = null)
{
foreach ($this->whereClauses as $clause) {
if ($operator) {
if (false !== stripos($clause, sprintf('%s %s', $field, $operator))) {
return true;
}
} elseif (false !== stripos($clause, $field)) {
return true;
}
}
return false;
}
/**
* Add a date range condition and enable cache if possible.
*
* @param string $field
* @param array|string $date
*
* @return $this
*/
public function whereDate($field, $date, $isLiveQuery = false)
{
if (empty($date)) {
return $this;
}
if (is_array($date)) {
$from = $date['from'] ?? '';
$to = $date['to'] ?? '';
} elseif (is_string($date)) {
$from = $date;
$to = $date;
} else {
return $this;
}
if ('dt' === $field) {
if (!empty($from) && !empty($to)) {
$fromTs = is_numeric($from) ? intval($from) : strtotime($from);
$toTs = is_numeric($to) ? intval($to) : strtotime($to);
$this->whereClauses[] = sprintf('%s BETWEEN %%s AND %%s', $field);
$this->valuesToPrepare[] = $fromTs;
$this->valuesToPrepare[] = $toTs;
$this->canUseCacheForDateRange($toTs);
if ($isLiveQuery) {
$this->_isLiveQuery = true;
}
}
} elseif (!empty($from) && !empty($to)) {
if (10 === strlen($from)) {
$from .= ' 00:00:00';
}
if (10 === strlen($to)) {
$to .= ' 23:59:59';
}
$this->whereClauses[] = sprintf('%s BETWEEN %%s AND %%s', $field);
$this->valuesToPrepare[] = $from;
$this->valuesToPrepare[] = $to;
$this->canUseCacheForDateRange($to);
if ($isLiveQuery) {
$this->_isLiveQuery = true;
}
}
return $this;
}
/**
* Executes a query for operations like INSERT, UPDATE, DELETE.
*
* @return int|bool Number of affected rows, or false on error. For INSERT, returns the insert ID.
* @throws \Exception
*/
public function execute()
{
if ('select' === $this->operation) {
throw new \Exception('execute() cannot be used for SELECT queries. Use getAll(), getVar(), getRow(), or getCol().');
}
$query = $this->buildQuery();
if (empty($query)) {
return false;
}
// SET values must come before WHERE values to match SQL clause order
$allValues = array_merge($this->setValuesToPrepare, $this->valuesToPrepare);
$prepared_query = $this->prepareQuery($query, $allValues);
$result = $this->db->query($prepared_query);
if ('insert' === $this->operation) {
return $this->db->insert_id ?: $result;
}
return $result;
}
public function getSqlQuery()
{
$query = $this->buildQuery();
// SET values must come before WHERE values to match SQL clause order
$allValues = array_merge($this->setValuesToPrepare, $this->valuesToPrepare);
return $this->prepareQuery($query, $allValues);
}
/**
* Get all results from a query.
* If this is a live query (i.e. the query has a live date range), this function will
* split the query into two parts: a historical part that can be safely cached, and a live
* part that should not be cached.
* If this is not a live query, the function will simply return the result of the query.
*
* @return array The result of the query
*/
public function getAll()
{
if (null !== $this->_isLiveQuery && $this->_isLiveQuery) {
$query = $this->buildQuery();
$query = $this->prepareQuery($query, $this->valuesToPrepare);
return $this->db->get_results($query, ARRAY_A);
}
$ranges = $this->extractDateRangesFromWhere();
if (count($ranges) > 1) {
$results = [];
foreach ($ranges as $range) {
if (empty($range['from']) || empty($range['to'])) {
continue;
}
$baseWhereClauses = $this->whereClauses;
$baseValuesToPrepare = $this->valuesToPrepare;
array_splice($baseWhereClauses, $range['clauseIdx'], 1);
array_splice($baseValuesToPrepare, $range['valueIdx'], 2);
$data = $this->processDateRange($range['from'], $range['to'], $baseWhereClauses, $baseValuesToPrepare);
if (is_array($data)) {
$results = array_merge($results, $data);
}
}
return $results;
}
[$split, $histFrom, $histTo, $liveFrom, $liveTo, $dtIdx, $dtClauseIdx] = $this->getSplitDateRanges();
if ($split) {
$baseWhereClauses = $this->whereClauses;
$baseValuesToPrepare = $this->valuesToPrepare;
array_splice($baseWhereClauses, $dtClauseIdx, 1);
$baseValues = $baseValuesToPrepare;
array_splice($baseValues, $dtIdx, 2);
// Remove OFFSET from sub-queries: each partition is smaller
// than the full date range, so applying the original OFFSET to
// each one independently can skip past all rows in that
// partition. Instead, fetch without OFFSET and apply it after
// merging.
$parsedOffset = 0;
$parsedLimit = 0;
if (preg_match('/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/i', $this->limitClause, $m)) {
$parsedLimit = intval($m[1]);
$parsedOffset = intval($m[2]);
} elseif (preg_match('/LIMIT\s+(\d+)/i', $this->limitClause, $m)) {
$parsedLimit = intval($m[1]);
}
// Sub-queries fetch up to offset+limit rows (no OFFSET) so we
// have enough data to slice after merging.
$subLimit = $parsedOffset + $parsedLimit;
// Clone for historical
$histQuery = clone $this;
$histQuery->whereClauses = $baseWhereClauses;
$histQuery->valuesToPrepare = $baseValues;
$histQuery->whereDate('dt', ['from' => $histFrom, 'to' => $histTo]);
$histQuery->allowCaching(true, $this->cacheExpiration);
if ($subLimit > 0) {
$histQuery->limit($subLimit);
}
try {
$historical = $histQuery->getAll();
} catch (Exception $e) {
$historical = [];
}
// Clone for live
$liveQuery = clone $this;
$liveQuery->whereClauses = $baseWhereClauses;
$liveQuery->valuesToPrepare = $baseValues;
$liveQuery->whereDate('dt', ['from' => $liveFrom, 'to' => $liveTo], true);
$liveQuery->allowCaching(false, 0);
if ($subLimit > 0) {
$liveQuery->limit($subLimit);
}
try {
$live = $liveQuery->getAll();
} catch (Exception $e) {
$live = [];
}
if (is_array($live)) {
$dtList = array_map(fn ($row) => $row['dt'] ?? null, $live);
}
// Only group-merge when the query has GROUP BY (aggregate queries).
// Raw SELECT queries (e.g. get_recent) must preserve duplicate rows.
if (!empty($this->groupByClause)) {
$merged = $this->mergeGroupResults($live, $historical);
} else {
$merged = array_merge($live, $historical);
}
// Re-sort merged results to honour the original ORDER BY.
// mergeGroupResults() sums counthits but loses sort order.
$merged = $this->sortMergedResults($merged);
if (is_array($merged)) {
$dtList = array_map(fn ($row) => $row['dt'] ?? null, $merged);
}
// Apply the original OFFSET+LIMIT after merging.
// Check $parsedLimit (not $parsedOffset) so "top" reports
// (which use LIMIT without OFFSET) also get capped after the
// two partitions are merged and re-sorted.
if ($parsedLimit > 0 && is_array($merged)) {
$merged = array_slice($merged, $parsedOffset, $parsedLimit);
}
return $merged;
}
$query = $this->buildQuery();
$query = $this->prepareQuery($query, $this->valuesToPrepare);
if ($this->allowCaching) {
try {
$cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
} catch (Exception $e) {
$cachedResult = false;
}
if (false !== $cachedResult) {
return $cachedResult;
}
}
try {
$result = $this->db->get_results($query, ARRAY_A);
} catch (Exception $exception) {
$result = [];
}
if ($this->allowCaching) {
try {
$this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
} catch (Exception $exception) {
// ignore
}
}
return $result;
}
}