dbCore = $dbCore; $this->f = $f !== null ? $f : abj_service('functions'); } /** * Roll up wp_abj404_logs_hits for every visible row's canonical URL in one * grouped query. Mirrors S9: SUM(logshits), MAX(logsid), MAX(last_used) by * requested_url. Returns an empty map (degraded path) when the logs_hits * table is absent, so a read on a stripped-down install still renders. * * @param array> $rows * @return array */ public function resolveHitsMap(array $rows): array { if (!$this->logsHitsTableExists()) { return array(); } $canonicals = array(); foreach ($rows as $row) { if (!is_array($row)) { continue; } $canonicals[$this->canonicalUrl($this->strField($row, 'url'))] = true; } if (empty($canonicals)) { return array(); } $urls = array(); foreach (array_keys($canonicals) as $canonical) { // Capture-derived URLs can carry invalid UTF-8 bytes; strip them // before they are bound so the IN() prefilter cannot break the query // (Pattern 10). The exact match below still uses the stored value. $urls[] = $this->f->sanitizeInvalidUTF8($canonical); } $list = ABJ_404_Solution_SqlFragmentTemplate::inList($urls); $query = "SELECT requested_url, SUM(logshits) AS logshits, MAX(logsid) AS logsid," . " MAX(last_used) AS last_used FROM {wp_abj404_logs_hits}" . " WHERE requested_url IN (" . $list['sql'] . ") GROUP BY requested_url"; $result = $this->dbCore->queryAndGetResults($query, array('query_params' => $list['params'])); $rowsOut = is_array($result['rows'] ?? null) ? $result['rows'] : array(); $map = array(); foreach ($rowsOut as $hitRow) { if (!is_array($hitRow) || !isset($hitRow['requested_url'])) { continue; } // Exact-string match in PHP keeps the binary-collation semantics the // staged S9 JOIN used; the IN() above is only a coarse prefilter. $map[$this->strField($hitRow, 'requested_url')] = array( 'logshits' => $this->intFieldOrNull($hitRow, 'logshits') ?? 0, 'logsid' => $this->intFieldOrNull($hitRow, 'logsid'), 'last_used' => $this->intFieldOrNull($hitRow, 'last_used'), ); } return $map; } /** @return bool */ private function logsHitsTableExists(): bool { $logsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}'); // Keep benign absence silent while routing the metadata read through // the central query pipeline. This gives AJAX diagnostics the same // preflight, callback, driver, recovery, and timeout boundaries as the // rollup query that follows it. // @utf8-audit: opt-out - $logsTable is an internally resolved plugin table name (doTableNameReplacements); system-controlled, cannot contain invalid UTF-8. $result = $this->dbCore->queryAndGetResults( "/* abj404:src=RedirectsHitsRollupReader#logs_hits_table_exists */ " . "SHOW TABLES LIKE %s", array('log_errors' => false, 'skip_repair' => true, 'timeout' => 10, 'query_params' => array($logsTable)) ); $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); $firstRow = is_array($rows[0] ?? null) ? $rows[0] : array(); $found = reset($firstRow); return $found === $logsTable; } /** * Safe string read of a query-result field; absent/non-scalar becomes ''. * @param array $row @param string $key @return string */ private function strField(array $row, string $key): string { return isset($row[$key]) && is_scalar($row[$key]) ? (string)$row[$key] : ''; } /** * Safe int read; NULL/absent/non-numeric stays null, numeric becomes int. * @param array $row @param string $key @return int|null */ private function intFieldOrNull(array $row, string $key): ?int { return ABJ_404_Solution_ExactInteger::read($row[$key] ?? null, 0); } /** @param string $url @return string */ private function canonicalUrl(string $url): string { return '/' . trim($url, '/'); } }