PluginProbe
SlimStat Analytics / trunk
SlimStat Analytics vtrunk
5.5.0 5.4.12 4.7.4 4.7.4.1 4.7.5 4.7.5.1 4.7.5.2 4.7.5.3 4.7.6 4.7.6.1 4.7.7 4.7.8 4.7.8.1 4.7.8.2 4.7.8.3 4.7.9 4.7.9.1 4.8 4.8.1 4.8.2 4.8.3 4.8.4 4.8.4.1 4.8.5 4.8.5.1 All 212 releases
wp-slimstat / src / Utils / Query.php

Query.php in SlimStat Analytics trunk, at src/Utils/Query.php

1,581 lines 53.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace SlimStat\Utils;
4
5 use InvalidArgumentException;
6
7 class Query
8 {
9 private $queries = [];
10
11 private $operation;
12
13 private $table;
14
15 private $fields = '*';
16
17 private $orderClause;
18
19 private $groupByClause;
20
21 private $havingClauses = [];
22
23 private $limitClause;
24
25 private $whereRelation = 'AND';
26
27 private $setClauses = [];
28
29 private $setValuesToPrepare = [];
30
31 private $joinClauses = [];
32
33 private $whereClauses = [];
34
35 private $rawWhereClause = [];
36
37 private $valuesToPrepare = [];
38
39 private $insertValues = [];
40
41 private $ignore = false;
42
43 private $allowCaching = false;
44
45 private $cacheExpiration = 3600;
46
47 protected $db;
48
49 private $_isLiveQuery = false;
50
51 private static $processingTimestamp = null;
52
53 /**
54 * Constructor.
55 *
56 * Initializes the query with the global $wpdb instance.
57 */
58 public function __construct()
59 {
60 $this->db = \wp_slimstat::$wpdb ?? $GLOBALS['wpdb'];
61 }
62
63 /**
64 * Initializes a new query instance for a select operation on a table.
65 *
66 * @param string|array $fields The fields to select. If an array is provided, the fields are
67 * concatenated with a comma separator and the resulting string
68 * is used as the SELECT clause.
69 *
70 * @return static A new Query instance configured for a select operation.
71 */
72 public static function select($fields = '*')
73 {
74 $instance = new self();
75 $instance->operation = 'select';
76 $instance->fields = is_array($fields) ? implode(', ', $fields) : $fields;
77 return $instance;
78 }
79
80 /**
81 * Initializes a new query instance for an update operation on the specified table.
82 *
83 * @param string $table The name of the table to update.
84 *
85 * @return static A new Query instance configured for an update operation.
86 */
87 public static function update($table)
88 {
89 $instance = new self();
90 $instance->operation = 'update';
91 $instance->table = $table;
92 return $instance;
93 }
94
95 /**
96 * Initializes a new query instance for a delete operation on the specified table.
97 *
98 * @param string $table The name of the table to delete from.
99 *
100 * @return static
101 */
102 public static function delete($table)
103 {
104 $instance = new self();
105 $instance->operation = 'delete';
106 $instance->table = $table;
107 return $instance;
108 }
109
110 /**
111 * Initializes a new query instance for an insert operation on the specified table.
112 *
113 * @param string $table The name of the table to insert data into.
114 *
115 * @return self A new Query instance configured for an insert operation.
116 */
117 public static function insert($table)
118 {
119 $instance = new self();
120 $instance->operation = 'insert';
121 $instance->table = $table;
122 return $instance;
123 }
124
125 /**
126 * Adds IGNORE to the query.
127 *
128 * @param bool $ignore
129 * @return $this
130 */
131 public function ignore($ignore = true)
132 {
133 $this->ignore = $ignore;
134 return $this;
135 }
136
137 /**
138 * Combines multiple query instances into a single UNION query.
139 *
140 * @param array $queries An array of Query instances to be united.
141 *
142 * @return self A new Query instance representing the UNION of the provided queries.
143 */
144 public static function union($queries)
145 {
146 $instance = new self();
147 $instance->operation = 'union';
148 $instance->queries = $queries;
149 return $instance;
150 }
151
152 /**
153 * Specifies the table to be used in the query.
154 *
155 * @param string $table The name of the table to use.
156 *
157 * @return $this
158 */
159 public function from($table)
160 {
161 $this->table = $table;
162 return $this;
163 }
164
165 /**
166 * Sets the values for an insert operation.
167 *
168 * @return $this
169 */
170 public function values(array $values)
171 {
172 if ($values === []) {
173 return $this;
174 }
175
176 // Check if it's an array of arrays for bulk insert
177 if (isset($values[0]) && is_array($values[0])) {
178 // Bulk insert
179 $this->insertValues = $values;
180 } else {
181 // Single row insert
182 $this->insertValues[] = $values;
183 }
184
185 return $this;
186 }
187
188 /**
189 * Sets the values for the columns in the current query.
190 *
191 * This function prepares the column assignments for an SQL update operation.
192 * It supports string, numeric, and null values, and automatically escapes
193 * field names to prevent SQL injection.
194 *
195 * @param array $values An associative array of column-value pairs to set.
196 * The array key is the column name, and the value is
197 * the value to assign to the column.
198 *
199 * @return $this
200 */
201 public function set($values)
202 {
203 if (empty($values)) {
204 return $this;
205 }
206
207 foreach ($values as $field => $value) {
208 $column = '`' . str_replace('`', '``', $field) . '`';
209 if (is_string($value)) {
210 $this->setClauses[] = sprintf('%s = %%s', $column);
211 $this->setValuesToPrepare[] = $value;
212 } elseif (is_numeric($value)) {
213 $this->setClauses[] = sprintf('%s = %%s', $column);
214 $this->setValuesToPrepare[] = $value;
215 } elseif (is_null($value)) {
216 $this->setClauses[] = $column . ' = NULL';
217 }
218 }
219
220 return $this;
221 }
222
223 /**
224 * Sets a raw value for a column, allowing for SQL expressions.
225 *
226 * @param string $column
227 * @param string $expression
228 * @param array $params
229 * @return $this
230 */
231 public function setRaw($column, $expression, $params = [])
232 {
233 $this->setClauses[] = sprintf('`%s` = %s', str_replace('`', '``', $column), $expression);
234 if (!empty($params)) {
235 $this->setValuesToPrepare = array_merge($this->setValuesToPrepare, $params);
236 }
237
238 return $this;
239 }
240
241 /**
242 * Add a WHERE clause to the query.
243 *
244 * @param string $field The field to filter on.
245 * @param string $operator The operator to use. Supported operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN.
246 * @param mixed $value The value to filter on. Can be a string, int, array or null.
247 *
248 * @return $this
249 *
250 * @throws InvalidArgumentException If the operator is not supported.
251 */
252 public function where($field, $operator, $value)
253 {
254 if ('BETWEEN' === strtoupper($operator) && is_array($value) && 2 === count($value) && (null !== $value[0] && null !== $value[1])) {
255 $condition = $this->generateCondition($field, $operator, $value);
256 if (!empty($condition)) {
257 $this->whereClauses[] = $condition['condition'];
258 $this->valuesToPrepare = array_merge($this->valuesToPrepare, $condition['values']);
259 }
260
261 return $this;
262 }
263
264 if (is_array($value)) {
265 $value = array_filter(array_values($value));
266 }
267
268 if (!is_numeric($value) && empty($value)) {
269 return $this;
270 }
271
272 $condition = $this->generateCondition($field, $operator, $value);
273 if (!empty($condition)) {
274 $this->whereClauses[] = $condition['condition'];
275 $this->valuesToPrepare = array_merge($this->valuesToPrepare, $condition['values']);
276 }
277
278 return $this;
279 }
280
281 /**
282 * Add a raw WHERE clause to the query. If values are provided, they will be
283 * escaped and inserted into the query.
284 *
285 * @param string $condition The raw WHERE condition.
286 * @param array $values Values to be inserted into the condition.
287 *
288 * @return $this
289 */
290 public function whereRaw($condition, $values = [])
291 {
292 $this->rawWhereClause[] = empty($values) ? $condition : $this->prepareQuery($condition, $values);
293
294 return $this;
295 }
296
297 /**
298 * Add a raw HAVING clause to the query. If values are provided, they will be
299 * escaped and inserted into the clause. Any leading "HAVING" keyword is stripped
300 * to ensure correct placement in the final query.
301 *
302 * @param string $condition The raw HAVING condition.
303 * @param array $values Values to be inserted into the condition.
304 *
305 * @return $this
306 */
307 public function havingRaw($condition, $values = [])
308 {
309 // Strip an optional leading HAVING keyword to avoid duplication
310 $condition = preg_replace('/^\s*HAVING\s+/i', '', $condition);
311 $this->havingClauses[] = empty($values) ? $condition : $this->prepareQuery($condition, $values);
312
313 return $this;
314 }
315
316 /**
317 * Sets the GROUP BY clause for the query.
318 *
319 * @param string|array $fields The fields to group by. Can be a comma-separated string or an array of fields.
320 *
321 * @return $this
322 */
323 public function groupBy($fields)
324 {
325 if (is_array($fields)) {
326 $fields = implode(', ', $fields);
327 }
328
329 if (!empty($fields)) {
330 $this->groupByClause = 'GROUP BY ' . $fields;
331 }
332
333 return $this;
334 }
335
336 /**
337 * Sets the ORDER BY clause for the query.
338 *
339 * @param string|array $fields The fields to order by. Can be a comma-separated string or an array of fields.
340 * @param string $order The order direction, either 'ASC' or 'DESC'. Defaults to 'DESC'.
341 *
342 * @return $this
343 */
344 public function orderBy($fields, $order = 'DESC')
345 {
346 if (empty($fields)) {
347 return $this;
348 }
349
350 if (is_string($fields)) {
351 if (preg_match('/\b(ASC|DESC)\b/i', $fields)) {
352 $this->orderClause = 'ORDER BY ' . $fields;
353 return $this;
354 }
355
356 $fields = explode(',', $fields);
357 $fields = array_map('trim', $fields);
358 }
359
360 if (is_array($fields)) {
361 $order = strtoupper($order);
362 if (!in_array($order, ['ASC', 'DESC'])) {
363 $order = 'DESC';
364 }
365
366 $orderParts = [];
367 foreach ($fields as $field) {
368 $orderParts[] = sprintf('%s %s', $field, $order);
369 }
370
371 $this->orderClause = 'ORDER BY ' . implode(', ', $orderParts);
372 }
373
374 return $this;
375 }
376
377 /**
378 * Sets the LIMIT clause for the query, with an optional OFFSET.
379 *
380 * @param int $limit The maximum number of results to return.
381 * @param int $offset The number of rows to skip. Defaults to 0.
382 *
383 * @return $this
384 */
385 public function limit($limit, $offset = 0)
386 {
387 $limit = intval($limit);
388 $offset = intval($offset);
389 if ($offset > 0) {
390 $this->limitClause = sprintf('LIMIT %d OFFSET %d', $limit, $offset);
391 } else {
392 $this->limitClause = 'LIMIT ' . $limit;
393 }
394 return $this;
395 }
396
397 /**
398 * Sets the LIMIT and OFFSET clauses for pagination.
399 *
400 * @param int $page The page number. Defaults to 1.
401 * @param int $perPage The number of results to show per page. Defaults to 10.
402 *
403 * @return $this
404 */
405 public function perPage($page = 1, $perPage = 10)
406 {
407 $page = intval($page);
408 $perPage = intval($perPage);
409 if ($page > 0 && $perPage > 0) {
410 $offset = ($page - 1) * $perPage;
411 $this->limitClause = sprintf('LIMIT %d OFFSET %d', $perPage, $offset);
412 }
413
414 return $this;
415 }
416
417 /**
418 * Join another table.
419 *
420 * @param string $table The table to join.
421 * @param string|array $on The join condition. Can be an array with two fields to join on, or a string with a condition.
422 * @param array $conditions An array of conditions to join on. Each condition is an array with three elements: field, operator, value.
423 * @param string $joinType The type of join. Can be INNER, LEFT, or RIGHT. Defaults to INNER.
424 *
425 * @return $this
426 *
427 * @throws InvalidArgumentException If the join condition is invalid.
428 */
429 public function join($table, $on, $conditions = [], $joinType = 'INNER')
430 {
431 $joinType = strtoupper($joinType);
432 if (is_array($on) && 2 == count($on)) {
433 $joinClause = sprintf('%s JOIN %s ON %s = %s', $joinType, $table, $on[0], $on[1]);
434 if (!empty($conditions)) {
435 foreach ($conditions as $condition) {
436 $field = $condition[0];
437 $operator = $condition[1];
438 $value = $condition[2];
439 $cond = $this->generateCondition($field, $operator, $value);
440 if (!empty($cond)) {
441 $joinClause .= ' AND ' . $cond['condition'];
442 $this->valuesToPrepare = array_merge($this->valuesToPrepare, $cond['values']);
443 }
444 }
445 }
446
447 $this->joinClauses[] = $joinClause;
448 return $this;
449 }
450
451 // Backward compatibility: allow two string fields passed separately
452 if (is_string($on) && is_string($conditions) && '' !== $on && '' !== $conditions) {
453 $this->joinClauses[] = sprintf('%s JOIN %s ON %s = %s', $joinType, $table, $on, $conditions);
454 return $this;
455 }
456
457 // Allow raw ON condition string
458 if (is_string($on) && '' !== $on && (empty($conditions) || (is_array($conditions) && empty($conditions)))) {
459 $this->joinClauses[] = sprintf('%s JOIN %s ON %s', $joinType, $table, $on);
460 return $this;
461 }
462
463 throw new InvalidArgumentException('Invalid join clause');
464 }
465
466 /**
467 * Set the caching flag and expiration time.
468 *
469 * @param bool $flag Whether to allow caching.
470 * @param int $expiration The cache expiration time in seconds.
471 *
472 * @return $this
473 */
474 public function allowCaching($flag = true, $expiration = 3600)
475 {
476 $this->allowCaching = $flag;
477 $this->cacheExpiration = $expiration;
478 return $this;
479 }
480
481 /**
482 * Set the caching flag depending on whether the given date range overlaps with today.
483 *
484 * If the given date range is entirely in the past, caching is allowed. Otherwise, caching is disabled.
485 *
486 * @param int|string $to The end date of the range (Y-m-d or Y-m-d H:i:s or timestamp)
487 *
488 * @return $this
489 */
490 public function canUseCacheForDateRange($to)
491 {
492 $today = $this->getTodayDate();
493 $toTs = is_numeric($to) ? intval($to) : strtotime($to);
494
495 if ($toTs < $today) {
496 $this->allowCaching(true, $this->cacheExpiration);
497 } else {
498 $this->allowCaching(false);
499 }
500 }
501
502 /**
503 * Set the processing timestamp context for caching decisions.
504 * This should be set to the timestamp of the event being processed (e.g., $stat['dt'])
505 * to ensure caching decisions are based on the event time, not the current server time.
506 *
507 * @param int|null $timestamp Unix timestamp of the event being processed, or null to use current time.
508 * @return void
509 */
510 public static function setProcessingTimestamp($timestamp)
511 {
512 self::$processingTimestamp = $timestamp;
513 }
514
515 /**
516 * Get the timestamp for the start of today.
517 * If a processing timestamp has been set, calculates "today" based on that timestamp.
518 * Otherwise, uses the current server time.
519 *
520 * @return int The timestamp for the start of today (midnight).
521 */
522 protected function getTodayDate()
523 {
524 if (null !== self::$processingTimestamp) {
525 return strtotime(date('Y-m-d 00:00:00', self::$processingTimestamp));
526 }
527 return strtotime(date('Y-m-d 00:00:00'));
528 }
529
530 protected function getCacheKey($input)
531 {
532 $normalized = $input;
533 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)) {
534 $from = $matches[1];
535 $to = $matches[2];
536 $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);
537 }
538
539 $normalized = preg_replace_callback('/(\d{4}-\d{2}-\d{2})[\s\d:]{0,8}/', fn ($m) => $m[1], $normalized);
540 $hash = substr(md5($normalized), 0, 10);
541 return sprintf('wp_slimstat_cache_%s', $hash);
542 }
543
544 protected function getCachedResult($input)
545 {
546 $cacheKey = $this->getCacheKey($input);
547 return get_transient($cacheKey);
548 }
549
550 protected function setCachedResult($input, $result, $expiration = DAY_IN_SECONDS)
551 {
552 $cacheKey = $this->getCacheKey($input);
553 return set_transient($cacheKey, $result, $expiration);
554 }
555
556 /**
557 * Analyzes the WHERE clauses to detect date ranges that overlap with today.
558 *
559 * This function iterates through the WHERE clauses to find any clause that specifies
560 * a date range (using "BETWEEN %s AND %s") and determines if this range overlaps
561 * with today. It extracts the timestamps for the start and end of the historical
562 * period (up to the start of today) and the live period (starting today).
563 *
564 * @return array<int|bool|null> An array containing:
565 * - boolean: whether a split range was found
566 * - int|null: historical start timestamp
567 * - int|null: historical end timestamp (inclusive)
568 * - int|null: live start timestamp (inclusive)
569 * - int|null: live end timestamp
570 */
571 protected function getSplitDateRanges2()
572 {
573 $dtField = 'dt';
574 $todayStart = $this->getTodayDate();
575 foreach ($this->whereClauses as $idx => $clause) {
576 if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
577 $from = null;
578 $to = null;
579 $dtIdx = 0;
580 foreach ($this->whereClauses as $i => $c) {
581 if ($i == $idx) {
582 break;
583 }
584
585 if (preg_match('/%s/', $c)) {
586 $dtIdx += substr_count($c, '%s');
587 }
588 }
589
590 $from = $this->valuesToPrepare[$dtIdx] ?? null;
591 $to = $this->valuesToPrepare[$dtIdx + 1] ?? null;
592 $fromTs = is_numeric($from) ? intval($from) : strtotime($from);
593 $toTs = is_numeric($to) ? intval($to) : strtotime($to);
594 if (null !== $fromTs && null !== $toTs && $fromTs < $todayStart && $toTs >= $todayStart) {
595 return [true, $fromTs, $todayStart - 1, $todayStart, $toTs];
596 }
597 }
598 }
599
600 return [false, null, null, null, null];
601 }
602
603 /**
604 * Helper: Generate a SQL condition based on the given field, operator and value.
605 *
606 * Supported operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN
607 *
608 * @param string $field Field name
609 * @param string $operator SQL operator
610 * @param mixed $value Value to be used in the condition. Can be a string, int, array or null.
611 *
612 * @return array|false Array with keys 'condition' and 'values', or false if the condition could not be generated.
613 *
614 * @throws InvalidArgumentException If the operator is not supported.
615 */
616 protected function generateCondition($field, $operator, $value)
617 {
618 $condition = '';
619 $values = [];
620 switch ($operator) {
621 case '=':
622 case '!=':
623 case '>':
624 case '>=':
625 case '<':
626 case '<=':
627 case 'LIKE':
628 case 'NOT LIKE':
629 $condition = sprintf('%s %s %%s', $field, $operator);
630 $values[] = $value;
631 break;
632 case 'IS':
633 case 'IS NOT':
634 if (is_null($value)) {
635 $condition = sprintf('%s %s NULL', $field, $operator);
636 }
637
638 break;
639 case 'IN':
640 case 'NOT IN':
641 if (is_string($value)) {
642 $value = explode(',', $value);
643 }
644
645 if (!empty($value) && is_array($value) && 1 == count($value)) {
646 $operator = ('IN' === $operator) ? '=' : '!=';
647 return $this->generateCondition($field, $operator, reset($value));
648 }
649
650 if (!empty($value) && is_array($value)) {
651 $placeholders = implode(', ', array_fill(0, count($value), '%s'));
652 $condition = sprintf('%s %s (%s)', $field, $operator, $placeholders);
653 $values = $value;
654 }
655
656 break;
657 case 'BETWEEN':
658 if (is_array($value) && 2 === count($value)) {
659 $condition = sprintf('%s BETWEEN %%s AND %%s', $field);
660 $values = $value;
661 }
662
663 break;
664 default:
665 throw new InvalidArgumentException('Unsupported operator: ' . $operator);
666 }
667
668 if ('' === $condition || '0' === $condition) {
669 return null;
670 }
671
672 return [
673 'condition' => $condition,
674 'values' => $values,
675 ];
676 }
677
678 /**
679 * Builds and returns an SQL query string based on the current operation and clauses.
680 *
681 * This function constructs a SQL query by assembling various parts such as the
682 * operation type (select, update, delete, insert, union), join clauses, where
683 * clauses, group by, order by, and limit clauses. It supports conditional logic
684 * to append appropriate SQL syntax based on the operation and provided clauses.
685 *
686 * @return string The constructed SQL query string.
687 *
688 * @throws InvalidArgumentException If the operation type is unknown.
689 */
690 protected function buildQuery()
691 {
692 switch ($this->operation) {
693 case 'select':
694 $query = sprintf('SELECT %s FROM %s', $this->fields, $this->table);
695 break;
696 case 'update':
697 $operation = $this->ignore ? 'UPDATE IGNORE' : 'UPDATE';
698 $query = sprintf('%s %s SET ', $operation, $this->table) . implode(', ', $this->setClauses);
699 break;
700 case 'delete':
701 $query = 'DELETE FROM ' . $this->table;
702 break;
703 case 'insert':
704 if (empty($this->insertValues)) {
705 return '';
706 }
707
708 $operation = $this->ignore ? 'INSERT IGNORE INTO' : 'INSERT INTO';
709 $sampleRow = $this->insertValues[0];
710 $keys = array_keys($sampleRow);
711 $query = sprintf('%s %s (`%s`) VALUES ', $operation, $this->table, implode('`, `', $keys));
712
713 $valueSets = [];
714 foreach ($this->insertValues as $row) {
715 $placeholders = implode(', ', array_fill(0, count($row), '%s'));
716 $valueSets[] = '(' . $placeholders . ')';
717 foreach ($row as $value) {
718 $this->valuesToPrepare[] = $value;
719 }
720 }
721
722 $query .= implode(', ', $valueSets);
723 break;
724 case 'union':
725 $query = implode(' UNION ', $this->queries);
726 break;
727 default:
728 throw new InvalidArgumentException('Unknown operation');
729 }
730
731 if (!empty($this->joinClauses)) {
732 $query .= ' ' . implode(' ', $this->joinClauses);
733 }
734
735 if (!empty($this->whereClauses)) {
736 $query .= ' WHERE ' . implode(sprintf(' %s ', $this->whereRelation), $this->whereClauses);
737 }
738
739 if (!empty($this->rawWhereClause)) {
740 $wrappedClauses = array_map(fn($clause) => "($clause)", $this->rawWhereClause);
741 if (!empty($this->whereClauses)) {
742 $query .= ' AND ' . implode(' AND ', $wrappedClauses);
743 } else {
744 $query .= ' WHERE ' . implode(' AND ', $wrappedClauses);
745 }
746 }
747
748 if (!empty($this->groupByClause)) {
749 $query .= ' ' . $this->groupByClause;
750 }
751
752 if (!empty($this->havingClauses)) {
753 $query .= ' HAVING ' . implode(' AND ', $this->havingClauses);
754 }
755
756 if (!empty($this->orderClause)) {
757 $query .= ' ' . $this->orderClause;
758 }
759
760 if (!empty($this->limitClause)) {
761 $query .= ' ' . $this->limitClause;
762 }
763
764 return $query;
765 }
766
767 /**
768 * Prepares a query for execution by replacing placeholders with actual values.
769 * Supported placeholders are %i, %s, %f, and %d.
770 * If the query contains more than one placeholder, the $args parameter should be an array with the same number of elements.
771 * If the query contains only one placeholder, the $args parameter can be either an array or a single value.
772 * If the query contains no placeholders, the $args parameter is ignored.
773 *
774 * @param string $query
775 * @param array $args
776 *
777 * @return string The prepared query
778 */
779 protected function prepareQuery($query, $args = [])
780 {
781 if (preg_match('/%[i|s|f|d]/', $query)) {
782 $placeholder_count = preg_match_all('/%[i|s|f|d]/', $query, $matches);
783 $args_count = is_array($args) ? count($args) : (empty($args) ? 0 : 1);
784 if (1 === $placeholder_count) {
785 $query = is_array($args) ? $this->db->prepare($query, reset($args)) : $this->db->prepare($query, $args);
786 } elseif (is_array($args) && $args_count === $placeholder_count) {
787 $query = $this->db->prepare($query, $args);
788 } else {
789 return $query;
790 }
791 }
792
793 return $query;
794 }
795
796 /**
797 * Generates a cache key for a given query and its arguments.
798 *
799 * This method serializes the query and arguments into a data array,
800 * creates an MD5 hash of the serialized data, and returns a truncated
801 * hash as a unique cache key prefixed with 'wp_slimstat_query_'.
802 *
803 * @param string $query The SQL query.
804 * @param array $args The query arguments.
805 *
806 * @return string The generated cache key.
807 */
808 protected function getCacheKeyForQuery($query, $args = [])
809 {
810 $data = [
811 'query' => $query,
812 'args' => $args,
813 ];
814 $hash = substr(md5(serialize($data)), 0, 16);
815 return 'wp_slimstat_query_' . $hash;
816 }
817
818 /**
819 * Retrieves the cached result for the given query and args
820 *
821 * @param string $query The SQL query
822 * @param array $args The query arguments
823 *
824 * @return mixed The query result, or false if there is no cached result
825 */
826 protected function getCachedResultForQuery($query, $args = [])
827 {
828 $cacheKey = $this->getCacheKeyForQuery($query, $args);
829 $data = get_transient($cacheKey);
830 if (false === $data) {
831 return false;
832 }
833
834 if (is_array($data) && isset($data['chunks']) && isset($data['size'])) {
835 $chunks = [];
836 for ($i = 0; $i < $data['chunks']; $i++) {
837 $chunk = get_transient($cacheKey . '_' . $i);
838 if (false === $chunk) {
839 return false;
840 }
841
842 $chunks[] = $chunk;
843 }
844
845 $data = implode('', $chunks);
846 } elseif (is_array($data)) {
847 // Data is already an array (from transient), return directly
848 return $data;
849 }
850
851 if (function_exists('gzuncompress') && is_string($data)) {
852 $first2 = substr($data, 0, 2);
853 if ("\x1f\x8b" === $first2 || "\x78\x9c" === $first2 || "\x78\xda" === $first2) {
854 $data = @gzuncompress($data);
855 }
856 }
857
858 // Use JSON decode instead of unserialize for security
859 $decoded = json_decode($data, true);
860 if (json_last_error() === JSON_ERROR_NONE) {
861 return $decoded;
862 }
863
864 // Return false if JSON decode failed (corrupted or legacy data)
865 return false;
866 }
867
868 /**
869 * Sets the transient cache for the given query and args
870 *
871 * @param string $query The SQL query
872 * @param array $args The query arguments
873 * @param mixed $result The query result
874 * @param int $expiration The cache expiration time, in seconds
875 *
876 * @return bool True if cache was successfully set, false otherwise
877 */
878 protected function setCachedResultForQuery($query, $args, $result, $expiration = 300)
879 {
880 $cacheKey = $this->getCacheKeyForQuery($query, $args);
881 // Use JSON encode instead of serialize for security
882 $data = wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
883 if (false === $data) {
884 return false;
885 }
886
887 $max_chunk_size = 900 * 1024; // 900KB
888 $old_meta = get_transient($cacheKey);
889 if (is_array($old_meta) && isset($old_meta['chunks'])) {
890 for ($i = 0; $i < $old_meta['chunks']; $i++) {
891 delete_transient($cacheKey . '_' . $i);
892 }
893 }
894
895 if (strlen($data) > $max_chunk_size) {
896 $chunks = str_split($data, $max_chunk_size);
897 $meta = [
898 'chunks' => count($chunks),
899 'size' => strlen($data),
900 ];
901 if (strlen(wp_json_encode($meta)) > $max_chunk_size) {
902 return false;
903 }
904
905 set_transient($cacheKey, $meta, $expiration);
906 foreach ($chunks as $i => $chunk) {
907 set_transient($cacheKey . '_' . $i, $chunk, $expiration);
908 }
909 } else {
910 set_transient($cacheKey, $data, $expiration);
911 }
912
913 return true;
914 }
915
916 /**
917 * Extracts a date range from the WHERE clause where the range overlaps with today.
918 * Returns an array with the following elements:
919 * - boolean: whether a split range was found
920 * - int: historical start timestamp
921 * - int: historical end timestamp (inclusive)
922 * - int: live start timestamp (inclusive)
923 * - int: live end timestamp
924 * - int: index of the date field in the WHERE clause
925 * - int: index of the WHERE clause with the date range
926 *
927 * @return array<int, int, int, int, int, int, int>
928 */
929 protected function getSplitDateRanges()
930 {
931 $dtField = 'dt';
932 $todayStart = $this->getTodayDate();
933 foreach ($this->whereClauses as $idx => $clause) {
934 if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
935 $dtIdx = 0;
936 foreach ($this->whereClauses as $i => $c) {
937 if ($i == $idx) {
938 break;
939 }
940
941 if (preg_match('/%s/', $c)) {
942 $dtIdx += substr_count($c, '%s');
943 }
944 }
945
946 $from = $this->valuesToPrepare[$dtIdx] ?? null;
947 $to = $this->valuesToPrepare[$dtIdx + 1] ?? null;
948 $fromTs = is_numeric($from) ? intval($from) : strtotime($from);
949 $toTs = is_numeric($to) ? intval($to) : strtotime($to);
950 if (null !== $fromTs && null !== $toTs && $fromTs < $todayStart && $toTs >= $todayStart) {
951 return [true, $fromTs, $todayStart - 1, $todayStart, $toTs, $dtIdx, $idx];
952 }
953 }
954 }
955
956 return [false, null, null, null, null, null, null];
957 }
958
959 /**
960 * Merges two arrays of result rows from the historical and live parts of a query.
961 * If $groupKey is set, the function will group the results by this key and sum the
962 * values in $sumFields for each group. Otherwise, the function will return the
963 * array merge of the two arrays.
964 *
965 * @param array $historical The result rows from the historical part of the query
966 * @param array $live The result rows from the live part of the query
967 * @param string $groupKey The key to group the results by
968 * @param array $sumFields The fields to sum for each group
969 *
970 * @return array The merged and grouped result rows
971 */
972 protected function mergeGroupResults($historical, $live, $groupKey = null, $sumFields = ['counthits'])
973 {
974 $historical = is_array($historical) ? $historical : [];
975 $live = is_array($live) ? $live : [];
976
977 // If no group key provided, try to determine it from the data
978 if (!$groupKey) {
979 // Try to find a suitable group key from the first row
980 $firstRow = !empty($historical) ? $historical[0] : (!empty($live) ? $live[0] : null);
981 if ($firstRow && is_array($firstRow)) {
982 // Use the first column that's not a sum field
983 foreach (array_keys($firstRow) as $key) {
984 if (!in_array($key, $sumFields)) {
985 $groupKey = $key;
986 break;
987 }
988 }
989 }
990
991 // If still no group key, just merge without grouping
992 if (!$groupKey) {
993 return array_merge($historical, $live);
994 }
995 }
996
997 $result = [];
998 foreach ($historical as $row) {
999 if (isset($row[$groupKey])) {
1000 $key = $row[$groupKey];
1001 $result[$key] = $row;
1002 }
1003 }
1004
1005 foreach ($live as $row) {
1006 if (isset($row[$groupKey])) {
1007 $key = $row[$groupKey];
1008 if (isset($result[$key])) {
1009 foreach ($sumFields as $field) {
1010 if (isset($row[$field])) {
1011 $result[$key][$field] += $row[$field];
1012 }
1013 }
1014 } else {
1015 $result[$key] = $row;
1016 }
1017 }
1018 }
1019
1020 return array_values($result);
1021 }
1022
1023 /**
1024 * Re-sort merged results to honour the original ORDER BY clause.
1025 *
1026 * After mergeGroupResults() sums counts from historical + live partitions,
1027 * the relative ordering may no longer match the SQL ORDER BY (e.g. a row
1028 * whose counthits grew after summing should move up). This method parses
1029 * the stored orderClause and applies an equivalent PHP usort().
1030 *
1031 * Only fields that exist as keys in the result rows are used for sorting;
1032 * SQL expressions (MAX(dt), REPLACE(…), etc.) are silently skipped because
1033 * they don't appear as array keys after $wpdb->get_results().
1034 *
1035 * @param array $results Merged result rows (associative arrays).
1036 *
1037 * @return array Re-sorted rows.
1038 */
1039 protected function sortMergedResults(array $results): array
1040 {
1041 if (empty($this->orderClause) || empty($results)) {
1042 return $results;
1043 }
1044
1045 // Strip "ORDER BY " prefix → "counthits DESC, resource ASC"
1046 $orderStr = preg_replace('/^ORDER\s+BY\s+/i', '', $this->orderClause);
1047 $parts = array_map('trim', explode(',', $orderStr));
1048
1049 // Determine which fields actually exist in the result rows.
1050 $availableKeys = array_keys($results[0]);
1051
1052 $sortFields = [];
1053 foreach ($parts as $part) {
1054 if (preg_match('/^(.+?)\s+(ASC|DESC)$/i', $part, $m)) {
1055 $field = $m[1];
1056 $dir = strtoupper($m[2]);
1057
1058 // Resolve SQL aggregate functions to their result-set alias.
1059 // e.g. MAX(dt) → dt (from "MAX(dt) AS dt" in the SELECT).
1060 $resolvedField = $field;
1061 if (!in_array($field, $availableKeys, true) && preg_match('/^(?:MAX|MIN|COUNT|SUM|AVG)\((\w+)\)$/i', $field, $aggMatch)) {
1062 $resolvedField = $aggMatch[1];
1063 }
1064
1065 if (in_array($resolvedField, $availableKeys, true)) {
1066 $sortFields[] = ['field' => $resolvedField, 'dir' => $dir];
1067 }
1068 }
1069 }
1070
1071 if (empty($sortFields)) {
1072 return $results;
1073 }
1074
1075 usort($results, static function ($a, $b) use ($sortFields) {
1076 foreach ($sortFields as $sf) {
1077 $field = $sf['field'];
1078 $valA = $a[$field] ?? null;
1079 $valB = $b[$field] ?? null;
1080
1081 // NULLs sort last regardless of direction.
1082 if (null === $valA && null === $valB) {
1083 continue;
1084 }
1085 if (null === $valA) {
1086 return 1;
1087 }
1088 if (null === $valB) {
1089 return -1;
1090 }
1091
1092 if (is_numeric($valA) && is_numeric($valB)) {
1093 $cmp = ((float) $valA <=> (float) $valB);
1094 } else {
1095 $cmp = strcmp((string) $valA, (string) $valB);
1096 }
1097
1098 if (0 !== $cmp) {
1099 return ('DESC' === $sf['dir']) ? -$cmp : $cmp;
1100 }
1101 }
1102
1103 return 0;
1104 });
1105
1106 return $results;
1107 }
1108
1109 /**
1110 * Helper: Extract date ranges from WHERE clauses
1111 *
1112 * @return array Array of extracted date ranges with keys from, to, clauseIdx, and valueIdx
1113 */
1114 protected function extractDateRangesFromWhere()
1115 {
1116 $dtField = 'dt';
1117 $ranges = [];
1118 $dtIdx = 0;
1119 foreach ($this->whereClauses as $idx => $clause) {
1120 if (preg_match('/' . $dtField . ' BETWEEN %s AND %s/', $clause)) {
1121 $from = $this->valuesToPrepare[$dtIdx] ?? null;
1122 $to = $this->valuesToPrepare[$dtIdx + 1] ?? null;
1123 $ranges[] = [
1124 'from' => $from,
1125 'to' => $to,
1126 'clauseIdx' => $idx,
1127 'valueIdx' => $dtIdx,
1128 ];
1129 }
1130
1131 if (preg_match_all('/%s/', $clause, $m)) {
1132 $dtIdx += count($m[0]);
1133 }
1134 }
1135
1136 return $ranges;
1137 }
1138
1139 /**
1140 * Helper: Process a date range query, splitting it into historical and live parts as needed.
1141 *
1142 * @param int|string $from Start date (Y-m-d or Y-m-d H:i:s or timestamp)
1143 * @param int|string $to End date (Y-m-d or Y-m-d H:i:s or timestamp)
1144 * @param array $baseWhereClauses where clauses to use for the query
1145 * @param array $baseValuesToPrepare values to prepare for the query
1146 *
1147 * @return array result set
1148 */
1149 protected function processDateRange($from, $to, $baseWhereClauses, $baseValuesToPrepare)
1150 {
1151 $todayStart = $this->getTodayDate();
1152 $fromTs = is_numeric($from) ? intval($from) : strtotime($from);
1153 $toTs = is_numeric($to) ? intval($to) : strtotime($to);
1154
1155 if ($fromTs >= $todayStart) {
1156 $liveQuery = clone $this;
1157 $liveQuery->whereClauses = $baseWhereClauses;
1158 $liveQuery->valuesToPrepare = $baseValuesToPrepare;
1159 $liveQuery->whereDate('dt', ['from' => $fromTs, 'to' => $toTs], true);
1160 $liveQuery->allowCaching(false, 0);
1161 return $liveQuery->getAll();
1162 }
1163
1164 if ($toTs < $todayStart) {
1165 $cacheQuery = clone $this;
1166 $cacheQuery->whereClauses = $baseWhereClauses;
1167 $cacheQuery->valuesToPrepare = $baseValuesToPrepare;
1168 $cacheQuery->whereDate('dt', ['from' => $fromTs, 'to' => $toTs]);
1169 $cacheQuery->allowCaching(true, $this->cacheExpiration);
1170 return $cacheQuery->getAll();
1171 }
1172
1173 $histQuery = clone $this;
1174 $histQuery->whereClauses = $baseWhereClauses;
1175 $histQuery->valuesToPrepare = $baseValuesToPrepare;
1176 $histQuery->whereDate('dt', ['from' => $fromTs, 'to' => $todayStart - 1]);
1177 $histQuery->allowCaching(true, $this->cacheExpiration);
1178
1179 $historical = $histQuery->getAll();
1180
1181 $liveQuery = clone $this;
1182 $liveQuery->whereClauses = $baseWhereClauses;
1183 $liveQuery->valuesToPrepare = $baseValuesToPrepare;
1184 $liveQuery->whereDate('dt', ['from' => $todayStart, 'to' => $toTs], true);
1185 $liveQuery->allowCaching(false, 0);
1186
1187 $live = $liveQuery->getAll();
1188
1189 if ($toTs == $todayStart) {
1190 return $historical;
1191 }
1192
1193 if ($todayStart - 1 < $fromTs) {
1194 return $live;
1195 }
1196
1197 return array_merge($historical, $live);
1198 }
1199
1200 /**
1201 * Execute the query and return a single value from the first row
1202 *
1203 * This is a shortcut for `getAll()[0][0]`
1204 *
1205 * @return mixed The value, or false/null if no rows are returned
1206 */
1207 public function getVar()
1208 {
1209 // When caching is enabled and the date range includes today, skip cache
1210 // to stay consistent with getAll() which always fetches fresh live data.
1211 // Without this, cached scalar values (e.g. $pageviews) become stale while
1212 // getAll() returns fresh grouped data, causing percentage calculations >100%.
1213 $useCache = $this->allowCaching;
1214 if ($useCache) {
1215 [$split] = $this->getSplitDateRanges();
1216 if ($split) {
1217 $useCache = false;
1218 }
1219 }
1220
1221 $query = $this->buildQuery();
1222 $query = $this->prepareQuery($query, $this->valuesToPrepare);
1223 if ($useCache) {
1224 $cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
1225 if (false !== $cachedResult) {
1226 return $cachedResult;
1227 }
1228 }
1229
1230 $result = $this->db->get_var($query);
1231 if ($useCache) {
1232 $this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
1233 }
1234
1235 return $result;
1236 }
1237
1238 /**
1239 * Execute the query and return a single row
1240 *
1241 * This is a shortcut for `getAll()[0]`
1242 *
1243 * @return array The row, or false/null if no rows are returned
1244 */
1245 public function getRow()
1246 {
1247 $useCache = $this->allowCaching;
1248 if ($useCache) {
1249 [$split] = $this->getSplitDateRanges();
1250 if ($split) {
1251 $useCache = false;
1252 }
1253 }
1254
1255 $query = $this->buildQuery();
1256 $query = $this->prepareQuery($query, $this->valuesToPrepare);
1257 if ($useCache) {
1258 $cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
1259 if (false !== $cachedResult) {
1260 return $cachedResult;
1261 }
1262 }
1263
1264 $result = $this->db->get_row($query);
1265 if ($useCache) {
1266 $this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
1267 }
1268
1269 return $result;
1270 }
1271
1272 /**
1273 * Execute the query and return a single column
1274 *
1275 * This is a shortcut for `getAll()`
1276 *
1277 * @return array The column, or false/null if no columns are returned
1278 */
1279 public function getCol()
1280 {
1281 $useCache = $this->allowCaching;
1282 if ($useCache) {
1283 [$split] = $this->getSplitDateRanges();
1284 if ($split) {
1285 $useCache = false;
1286 }
1287 }
1288
1289 $query = $this->buildQuery();
1290 $query = $this->prepareQuery($query, $this->valuesToPrepare);
1291 if ($useCache) {
1292 $cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
1293 if (false !== $cachedResult) {
1294 return $cachedResult;
1295 }
1296 }
1297
1298 $result = $this->db->get_col($query);
1299 if ($useCache) {
1300 $this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
1301 }
1302
1303 return $result;
1304 }
1305
1306 /**
1307 * Check if a where clause for a field/operator exists (e.g. 'dt BETWEEN').
1308 *
1309 * @param string $field
1310 * @param string|null $operator
1311 *
1312 * @return bool
1313 */
1314 public function hasWhereClause(string $field, ?string $operator = null)
1315 {
1316 foreach ($this->whereClauses as $clause) {
1317 if ($operator) {
1318 if (false !== stripos($clause, sprintf('%s %s', $field, $operator))) {
1319 return true;
1320 }
1321 } elseif (false !== stripos($clause, $field)) {
1322 return true;
1323 }
1324 }
1325
1326 return false;
1327 }
1328
1329 /**
1330 * Add a date range condition and enable cache if possible.
1331 *
1332 * @param string $field
1333 * @param array|string $date
1334 *
1335 * @return $this
1336 */
1337 public function whereDate($field, $date, $isLiveQuery = false)
1338 {
1339 if (empty($date)) {
1340 return $this;
1341 }
1342
1343 if (is_array($date)) {
1344 $from = $date['from'] ?? '';
1345 $to = $date['to'] ?? '';
1346 } elseif (is_string($date)) {
1347 $from = $date;
1348 $to = $date;
1349 } else {
1350 return $this;
1351 }
1352
1353 if ('dt' === $field) {
1354 if (!empty($from) && !empty($to)) {
1355 $fromTs = is_numeric($from) ? intval($from) : strtotime($from);
1356 $toTs = is_numeric($to) ? intval($to) : strtotime($to);
1357
1358 $this->whereClauses[] = sprintf('%s BETWEEN %%s AND %%s', $field);
1359 $this->valuesToPrepare[] = $fromTs;
1360 $this->valuesToPrepare[] = $toTs;
1361 $this->canUseCacheForDateRange($toTs);
1362 if ($isLiveQuery) {
1363 $this->_isLiveQuery = true;
1364 }
1365 }
1366 } elseif (!empty($from) && !empty($to)) {
1367 if (10 === strlen($from)) {
1368 $from .= ' 00:00:00';
1369 }
1370
1371 if (10 === strlen($to)) {
1372 $to .= ' 23:59:59';
1373 }
1374
1375 $this->whereClauses[] = sprintf('%s BETWEEN %%s AND %%s', $field);
1376 $this->valuesToPrepare[] = $from;
1377 $this->valuesToPrepare[] = $to;
1378 $this->canUseCacheForDateRange($to);
1379 if ($isLiveQuery) {
1380 $this->_isLiveQuery = true;
1381 }
1382 }
1383
1384 return $this;
1385 }
1386
1387 /**
1388 * Executes a query for operations like INSERT, UPDATE, DELETE.
1389 *
1390 * @return int|bool Number of affected rows, or false on error. For INSERT, returns the insert ID.
1391 * @throws \Exception
1392 */
1393 public function execute()
1394 {
1395 if ('select' === $this->operation) {
1396 throw new \Exception('execute() cannot be used for SELECT queries. Use getAll(), getVar(), getRow(), or getCol().');
1397 }
1398
1399 $query = $this->buildQuery();
1400
1401 if (empty($query)) {
1402 return false;
1403 }
1404
1405 // SET values must come before WHERE values to match SQL clause order
1406 $allValues = array_merge($this->setValuesToPrepare, $this->valuesToPrepare);
1407 $prepared_query = $this->prepareQuery($query, $allValues);
1408
1409 $result = $this->db->query($prepared_query);
1410
1411 if ('insert' === $this->operation) {
1412 return $this->db->insert_id ?: $result;
1413 }
1414
1415 return $result;
1416 }
1417
1418 public function getSqlQuery()
1419 {
1420 $query = $this->buildQuery();
1421 // SET values must come before WHERE values to match SQL clause order
1422 $allValues = array_merge($this->setValuesToPrepare, $this->valuesToPrepare);
1423 return $this->prepareQuery($query, $allValues);
1424 }
1425
1426 /**
1427 * Get all results from a query.
1428 * If this is a live query (i.e. the query has a live date range), this function will
1429 * split the query into two parts: a historical part that can be safely cached, and a live
1430 * part that should not be cached.
1431 * If this is not a live query, the function will simply return the result of the query.
1432 *
1433 * @return array The result of the query
1434 */
1435 public function getAll()
1436 {
1437 if (null !== $this->_isLiveQuery && $this->_isLiveQuery) {
1438 $query = $this->buildQuery();
1439 $query = $this->prepareQuery($query, $this->valuesToPrepare);
1440 return $this->db->get_results($query, ARRAY_A);
1441 }
1442
1443 $ranges = $this->extractDateRangesFromWhere();
1444 if (count($ranges) > 1) {
1445 $results = [];
1446 foreach ($ranges as $range) {
1447 if (empty($range['from']) || empty($range['to'])) {
1448 continue;
1449 }
1450
1451 $baseWhereClauses = $this->whereClauses;
1452 $baseValuesToPrepare = $this->valuesToPrepare;
1453 array_splice($baseWhereClauses, $range['clauseIdx'], 1);
1454 array_splice($baseValuesToPrepare, $range['valueIdx'], 2);
1455 $data = $this->processDateRange($range['from'], $range['to'], $baseWhereClauses, $baseValuesToPrepare);
1456 if (is_array($data)) {
1457 $results = array_merge($results, $data);
1458 }
1459 }
1460
1461 return $results;
1462 }
1463
1464 [$split, $histFrom, $histTo, $liveFrom, $liveTo, $dtIdx, $dtClauseIdx] = $this->getSplitDateRanges();
1465 if ($split) {
1466 $baseWhereClauses = $this->whereClauses;
1467 $baseValuesToPrepare = $this->valuesToPrepare;
1468 array_splice($baseWhereClauses, $dtClauseIdx, 1);
1469 $baseValues = $baseValuesToPrepare;
1470 array_splice($baseValues, $dtIdx, 2);
1471
1472 // Remove OFFSET from sub-queries: each partition is smaller
1473 // than the full date range, so applying the original OFFSET to
1474 // each one independently can skip past all rows in that
1475 // partition. Instead, fetch without OFFSET and apply it after
1476 // merging.
1477 $parsedOffset = 0;
1478 $parsedLimit = 0;
1479 if (preg_match('/LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/i', $this->limitClause, $m)) {
1480 $parsedLimit = intval($m[1]);
1481 $parsedOffset = intval($m[2]);
1482 } elseif (preg_match('/LIMIT\s+(\d+)/i', $this->limitClause, $m)) {
1483 $parsedLimit = intval($m[1]);
1484 }
1485 // Sub-queries fetch up to offset+limit rows (no OFFSET) so we
1486 // have enough data to slice after merging.
1487 $subLimit = $parsedOffset + $parsedLimit;
1488
1489 // Clone for historical
1490 $histQuery = clone $this;
1491 $histQuery->whereClauses = $baseWhereClauses;
1492 $histQuery->valuesToPrepare = $baseValues;
1493 $histQuery->whereDate('dt', ['from' => $histFrom, 'to' => $histTo]);
1494 $histQuery->allowCaching(true, $this->cacheExpiration);
1495 if ($subLimit > 0) {
1496 $histQuery->limit($subLimit);
1497 }
1498 try {
1499 $historical = $histQuery->getAll();
1500 } catch (Exception $e) {
1501 $historical = [];
1502 }
1503
1504 // Clone for live
1505 $liveQuery = clone $this;
1506 $liveQuery->whereClauses = $baseWhereClauses;
1507 $liveQuery->valuesToPrepare = $baseValues;
1508 $liveQuery->whereDate('dt', ['from' => $liveFrom, 'to' => $liveTo], true);
1509 $liveQuery->allowCaching(false, 0);
1510 if ($subLimit > 0) {
1511 $liveQuery->limit($subLimit);
1512 }
1513 try {
1514 $live = $liveQuery->getAll();
1515 } catch (Exception $e) {
1516 $live = [];
1517 }
1518
1519 if (is_array($live)) {
1520 $dtList = array_map(fn ($row) => $row['dt'] ?? null, $live);
1521 }
1522
1523 // Only group-merge when the query has GROUP BY (aggregate queries).
1524 // Raw SELECT queries (e.g. get_recent) must preserve duplicate rows.
1525 if (!empty($this->groupByClause)) {
1526 $merged = $this->mergeGroupResults($live, $historical);
1527 } else {
1528 $merged = array_merge($live, $historical);
1529 }
1530
1531 // Re-sort merged results to honour the original ORDER BY.
1532 // mergeGroupResults() sums counthits but loses sort order.
1533 $merged = $this->sortMergedResults($merged);
1534
1535 if (is_array($merged)) {
1536 $dtList = array_map(fn ($row) => $row['dt'] ?? null, $merged);
1537 }
1538
1539 // Apply the original OFFSET+LIMIT after merging.
1540 // Check $parsedLimit (not $parsedOffset) so "top" reports
1541 // (which use LIMIT without OFFSET) also get capped after the
1542 // two partitions are merged and re-sorted.
1543 if ($parsedLimit > 0 && is_array($merged)) {
1544 $merged = array_slice($merged, $parsedOffset, $parsedLimit);
1545 }
1546
1547 return $merged;
1548 }
1549
1550 $query = $this->buildQuery();
1551 $query = $this->prepareQuery($query, $this->valuesToPrepare);
1552 if ($this->allowCaching) {
1553 try {
1554 $cachedResult = $this->getCachedResultForQuery($query, $this->valuesToPrepare);
1555 } catch (Exception $e) {
1556 $cachedResult = false;
1557 }
1558
1559 if (false !== $cachedResult) {
1560 return $cachedResult;
1561 }
1562 }
1563
1564 try {
1565 $result = $this->db->get_results($query, ARRAY_A);
1566 } catch (Exception $exception) {
1567 $result = [];
1568 }
1569
1570 if ($this->allowCaching) {
1571 try {
1572 $this->setCachedResultForQuery($query, $this->valuesToPrepare, $result, $this->cacheExpiration);
1573 } catch (Exception $exception) {
1574 // ignore
1575 }
1576 }
1577
1578 return $result;
1579 }
1580 }
1581