| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** Query failure for the privacy-bucketed redirect hit-count histogram. */ |
| 8 |
class ABJ_404_Solution_RedirectHitCountHistogramQueryException extends RuntimeException { |
| 9 |
} |
| 10 |
|
| 11 |
/** |
| 12 |
* Reads the privacy-preserving redirect hit-count telemetry histogram. |
| 13 |
* |
| 14 |
* This repository deliberately returns only fixed aggregate buckets. It must |
| 15 |
* never expose redirect URLs or per-row traffic patterns. Query failures throw |
| 16 |
* so the diagnostics collector omits the field instead of publishing a false |
| 17 |
* zero histogram. |
| 18 |
*/ |
| 19 |
class ABJ_404_Solution_RedirectHitCountHistogramRepository { |
| 20 |
|
| 21 |
/** @var ABJ_404_Solution_DatabaseQueryInterface */ |
| 22 |
private $dbCore; |
| 23 |
|
| 24 |
/** @var ABJ_404_Solution_TableReadinessGate */ |
| 25 |
private $readiness; |
| 26 |
|
| 27 |
public function __construct( |
| 28 |
ABJ_404_Solution_DatabaseQueryInterface $dbCore, |
| 29 |
ABJ_404_Solution_TableReadinessGate $readiness |
| 30 |
) { |
| 31 |
$this->dbCore = $dbCore; |
| 32 |
$this->readiness = $readiness; |
| 33 |
} |
| 34 |
|
| 35 |
/** |
| 36 |
* Aggregate active redirects into fixed denormalized hit-count buckets. |
| 37 |
* |
| 38 |
* @return array<string, int> |
| 39 |
* @throws ABJ_404_Solution_RedirectHitCountHistogramQueryException |
| 40 |
*/ |
| 41 |
public function getRedirectHitCountHistogram(): array { |
| 42 |
$emptyHistogram = array( |
| 43 |
'zero_hits' => 0, |
| 44 |
'one_to_ten_hits' => 0, |
| 45 |
'eleven_to_hundred_hits' => 0, |
| 46 |
'over_hundred_hits' => 0, |
| 47 |
); |
| 48 |
if ($this->readiness->isKnownAbsent('{wp_abj404_redirects}')) { |
| 49 |
return $emptyHistogram; |
| 50 |
} |
| 51 |
|
| 52 |
$redirectStatuses = ABJ404_STATUS_MANUAL . ", " . ABJ404_STATUS_AUTO . ", " . ABJ404_STATUS_REGEX; |
| 53 |
$query = "SELECT |
| 54 |
SUM(CASE WHEN disabled = 0 AND status IN (" . $redirectStatuses . ") AND logshits <= 0 THEN 1 ELSE 0 END) as zero_hits, |
| 55 |
SUM(CASE WHEN disabled = 0 AND status IN (" . $redirectStatuses . ") AND logshits BETWEEN 1 AND 10 THEN 1 ELSE 0 END) as one_to_ten_hits, |
| 56 |
SUM(CASE WHEN disabled = 0 AND status IN (" . $redirectStatuses . ") AND logshits BETWEEN 11 AND 100 THEN 1 ELSE 0 END) as eleven_to_hundred_hits, |
| 57 |
SUM(CASE WHEN disabled = 0 AND status IN (" . $redirectStatuses . ") AND logshits > 100 THEN 1 ELSE 0 END) as over_hundred_hits |
| 58 |
FROM {wp_abj404_redirects} |
| 59 |
WHERE status IN (" . $redirectStatuses . ")"; |
| 60 |
$query = $this->dbCore->doTableNameReplacements($query); |
| 61 |
|
| 62 |
$result = $this->dbCore->queryAndGetResults($query); |
| 63 |
if (!empty($result['last_error']) || !empty($result['timed_out'])) { |
| 64 |
$lastError = $result['last_error'] ?? 'unknown'; |
| 65 |
$lastErrorText = is_scalar($lastError) |
| 66 |
? (string)$lastError |
| 67 |
: (is_object($lastError) ? get_class($lastError) : gettype($lastError)); |
| 68 |
$context = !empty($result['timed_out']) |
| 69 |
? 'timed_out=true' |
| 70 |
: 'last_error=' . $lastErrorText; |
| 71 |
throw new ABJ_404_Solution_RedirectHitCountHistogramQueryException( |
| 72 |
'Redirect hit histogram query failed (' . $context . ')' |
| 73 |
); |
| 74 |
} |
| 75 |
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); |
| 76 |
$row = !empty($rows) && is_array($rows[0] ?? null) ? $rows[0] : array(); |
| 77 |
|
| 78 |
return array( |
| 79 |
'zero_hits' => self::scalarToInt($row['zero_hits'] ?? 0), |
| 80 |
'one_to_ten_hits' => self::scalarToInt($row['one_to_ten_hits'] ?? 0), |
| 81 |
'eleven_to_hundred_hits' => self::scalarToInt($row['eleven_to_hundred_hits'] ?? 0), |
| 82 |
'over_hundred_hits' => self::scalarToInt($row['over_hundred_hits'] ?? 0), |
| 83 |
); |
| 84 |
} |
| 85 |
|
| 86 |
/** @param mixed $value */ |
| 87 |
private static function scalarToInt($value): int { |
| 88 |
return is_scalar($value) ? intval($value) : 0; |
| 89 |
} |
| 90 |
} |
| 91 |
|