| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
trait ABJ_404_Solution_DataAccess_ViewQueriesTrait { |
| 8 |
|
| 9 |
/** |
| 10 |
* Get counts for each redirect status type for display in tabs. |
| 11 |
* Uses transient caching for performance. |
| 12 |
* @param bool $bypassCache If true, skip cache and query database directly |
| 13 |
* @return array<string, int> An array with keys: all, manual, auto, regex, trash |
| 14 |
*/ |
| 15 |
function getRedirectStatusCounts($bypassCache = false) { |
| 16 |
// Try to get cached value first |
| 17 |
if (!$bypassCache) { |
| 18 |
$cached = get_transient(self::CACHE_KEY_REDIRECT_STATUS); |
| 19 |
if ($cached !== false && is_array($cached)) { |
| 20 |
/** @var array<string, int> $cached */ |
| 21 |
return $cached; |
| 22 |
} |
| 23 |
} |
| 24 |
|
| 25 |
// IMPORTANT: The redirects table also stores captured/ignored/later rows. |
| 26 |
// The Redirects page "All/Manual/Auto/Trash" tabs should only count actual redirects |
| 27 |
// (manual/auto/regex), not captured URLs. |
| 28 |
$query = "SELECT |
| 29 |
SUM(CASE WHEN disabled = 0 THEN 1 ELSE 0 END) as active_count, |
| 30 |
SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_MANUAL . " THEN 1 ELSE 0 END) as manual_count, |
| 31 |
SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_AUTO . " THEN 1 ELSE 0 END) as auto_count, |
| 32 |
SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_REGEX . " THEN 1 ELSE 0 END) as regex_count, |
| 33 |
SUM(CASE WHEN disabled = 1 THEN 1 ELSE 0 END) as trash_count |
| 34 |
FROM {wp_abj404_redirects} |
| 35 |
WHERE status IN (" . ABJ404_STATUS_MANUAL . ", " . ABJ404_STATUS_AUTO . ", " . ABJ404_STATUS_REGEX . ")"; |
| 36 |
$query = $this->doTableNameReplacements($query); |
| 37 |
|
| 38 |
$result = $this->queryAndGetResults($query); |
| 39 |
$hadError = !empty($result['last_error']) || !empty($result['timed_out']); |
| 40 |
$rows = is_array($result['rows']) ? $result['rows'] : array(); |
| 41 |
|
| 42 |
$counts = array('all' => 0, 'manual' => 0, 'auto' => 0, 'regex' => 0, 'trash' => 0); |
| 43 |
if (!empty($rows)) { |
| 44 |
$row = is_array($rows[0] ?? null) ? $rows[0] : array(); |
| 45 |
$counts = array( |
| 46 |
'all' => intval(is_scalar($row['active_count'] ?? 0) ? $row['active_count'] : 0), |
| 47 |
'manual' => intval(is_scalar($row['manual_count'] ?? 0) ? $row['manual_count'] : 0), |
| 48 |
'auto' => intval(is_scalar($row['auto_count'] ?? 0) ? $row['auto_count'] : 0), |
| 49 |
'regex' => intval(is_scalar($row['regex_count'] ?? 0) ? $row['regex_count'] : 0), |
| 50 |
'trash' => intval(is_scalar($row['trash_count'] ?? 0) ? $row['trash_count'] : 0) |
| 51 |
); |
| 52 |
} |
| 53 |
|
| 54 |
// Skip the cache write when the SUM(...) query returned an error or |
| 55 |
// timed out: $rows is empty in that case so $counts is the all-zero |
| 56 |
// default, and pinning that for STATUS_CACHE_TTL (24h) would make the |
| 57 |
// Redirects admin page show "0 of every status" until the transient |
| 58 |
// expires. Same policy as 6454a7dd / b857be36. |
| 59 |
if (!$hadError) { |
| 60 |
set_transient(self::CACHE_KEY_REDIRECT_STATUS, $counts, self::STATUS_CACHE_TTL); |
| 61 |
} |
| 62 |
|
| 63 |
return $counts; |
| 64 |
} |
| 65 |
|
| 66 |
/** |
| 67 |
* Get counts for each captured URL status type. |
| 68 |
* Uses transient caching for performance. |
| 69 |
* @param bool $bypassCache If true, skip cache and query database directly |
| 70 |
* @return array<string, int> Array with keys: all, captured, ignored, later, trash |
| 71 |
*/ |
| 72 |
function getCapturedStatusCounts($bypassCache = false) { |
| 73 |
// Try to get cached value first |
| 74 |
if (!$bypassCache) { |
| 75 |
$cached = get_transient(self::CACHE_KEY_CAPTURED_STATUS); |
| 76 |
if ($cached !== false && is_array($cached)) { |
| 77 |
/** @var array<string, int> $cached */ |
| 78 |
return $cached; |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
$query = "SELECT |
| 83 |
COUNT(*) as total, |
| 84 |
SUM(CASE WHEN disabled = 0 THEN 1 ELSE 0 END) as active, |
| 85 |
SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_CAPTURED . " THEN 1 ELSE 0 END) as captured, |
| 86 |
SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_IGNORED . " THEN 1 ELSE 0 END) as ignored, |
| 87 |
SUM(CASE WHEN disabled = 0 AND status = " . ABJ404_STATUS_LATER . " THEN 1 ELSE 0 END) as later, |
| 88 |
SUM(CASE WHEN disabled = 1 THEN 1 ELSE 0 END) as trash |
| 89 |
FROM {wp_abj404_redirects} |
| 90 |
WHERE status IN (" . ABJ404_STATUS_CAPTURED . ", " . ABJ404_STATUS_IGNORED . ", " . ABJ404_STATUS_LATER . ")"; |
| 91 |
$query = $this->doTableNameReplacements($query); |
| 92 |
|
| 93 |
$result = $this->queryAndGetResults($query); |
| 94 |
$hadError = !empty($result['last_error']) || !empty($result['timed_out']); |
| 95 |
$rows = is_array($result['rows']) ? $result['rows'] : array(); |
| 96 |
|
| 97 |
$counts = array('all' => 0, 'captured' => 0, 'ignored' => 0, 'later' => 0, 'trash' => 0); |
| 98 |
if (!empty($rows)) { |
| 99 |
$row = is_array($rows[0] ?? null) ? $rows[0] : array(); |
| 100 |
$counts = array( |
| 101 |
'all' => intval(is_scalar($row['active'] ?? 0) ? $row['active'] : 0), |
| 102 |
'captured' => intval(is_scalar($row['captured'] ?? 0) ? $row['captured'] : 0), |
| 103 |
'ignored' => intval(is_scalar($row['ignored'] ?? 0) ? $row['ignored'] : 0), |
| 104 |
'later' => intval(is_scalar($row['later'] ?? 0) ? $row['later'] : 0), |
| 105 |
'trash' => intval(is_scalar($row['trash'] ?? 0) ? $row['trash'] : 0) |
| 106 |
); |
| 107 |
} |
| 108 |
|
| 109 |
// Skip the cache write when the SUM(...) query returned an error or |
| 110 |
// timed out: $rows is empty in that case so $counts is the all-zero |
| 111 |
// default, and pinning that for STATUS_CACHE_TTL (24h) would make the |
| 112 |
// Captured-URLs admin page show "0 captured / 0 ignored / 0 later" |
| 113 |
// until the transient expires. Same policy as 6454a7dd / b857be36. |
| 114 |
if (!$hadError) { |
| 115 |
set_transient(self::CACHE_KEY_CAPTURED_STATUS, $counts, self::STATUS_CACHE_TTL); |
| 116 |
} |
| 117 |
|
| 118 |
return $counts; |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Count captured URLs that have been hit 3 or more times (signal of real user impact). |
| 123 |
* Uses transient caching for performance. |
| 124 |
* |
| 125 |
* Implementation: INNER JOIN against the pre-aggregated logs_hits rollup, |
| 126 |
* which already stores logshits per requested_url and is rebuilt by cron via |
| 127 |
* createRedirectsForViewHitsTable(). Same JOIN shape as |
| 128 |
* getRedirectsForViewQuery() — BINARY column equality. |
| 129 |
* |
| 130 |
* The previous implementation aggregated logsv2 with GROUP BY + HAVING per |
| 131 |
* call; on busy sites with millions of log rows that took 30–60s and hit |
| 132 |
* the AJAX timeout. The pre-aggregated table makes the count O(distinct |
| 133 |
* URLs) instead of O(total log rows). |
| 134 |
* |
| 135 |
* Fallback: if logs_hits is missing or empty, return 0 and schedule a |
| 136 |
* shutdown-time rebuild so a subsequent request can serve real data. We |
| 137 |
* deliberately do NOT fall back to the old logsv2 GROUP BY query: the whole |
| 138 |
* point of this function is to never run that scan again. |
| 139 |
* |
| 140 |
* Cache policy: only the result of a successful query against a populated |
| 141 |
* rollup is cached for STATUS_CACHE_TTL (24h). Errors, timeouts, missing |
| 142 |
* rollups, and empty-during-rebuild outcomes all return 0 *without |
| 143 |
* caching* so the next request retries — otherwise a single transient |
| 144 |
* failure would hide repeat-visitor URLs for a full day. |
| 145 |
* |
| 146 |
* @return int Number of captured URLs with 3+ log hits |
| 147 |
*/ |
| 148 |
function getHighImpactCapturedCount(): int { |
| 149 |
$cached = get_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED); |
| 150 |
if ($cached !== false) { |
| 151 |
return intval(is_scalar($cached) ? $cached : 0); |
| 152 |
} |
| 153 |
|
| 154 |
// If the rollup is not available, defer rather than scan logsv2. |
| 155 |
// Do not cache: the rebuild is in flight and the next request should retry. |
| 156 |
if (!$this->logsHitsTableExists()) { |
| 157 |
$this->scheduleHitsTableRebuild(); |
| 158 |
return 0; |
| 159 |
} |
| 160 |
|
| 161 |
$query = $this->buildHighImpactCapturedCountQuery(); |
| 162 |
|
| 163 |
$result = $this->queryWithTimeout($query, 60); |
| 164 |
$timedOut = !empty($result['timed_out']); |
| 165 |
$hadError = !empty($result['last_error']) || $timedOut; |
| 166 |
$rows = is_array($result['rows']) ? $result['rows'] : array(); |
| 167 |
$count = (!empty($rows) && isset($rows[0]['cnt'])) ? intval($rows[0]['cnt']) : 0; |
| 168 |
|
| 169 |
// Timeout self-heal (Bruno regression). Without this branch every |
| 170 |
// admin pageview re-pays the 60s timeout cost. We schedule a hits |
| 171 |
// table rebuild so the next post-cache request can return real |
| 172 |
// data, and cache 0 for the short STATUS_CACHE_TIMEOUT_SELFHEAL_TTL |
| 173 |
// window (5 min) so subsequent pageviews are instant. The short |
| 174 |
// TTL is far less than STATUS_CACHE_TTL (24h), so a transient |
| 175 |
// timeout cannot hide repeat-visitor URLs for a full day. |
| 176 |
if ($timedOut) { |
| 177 |
$this->scheduleHitsTableRebuild(); |
| 178 |
// allow-cache-empty: timeout self-heal sentinel, 5-minute window. Real value returns once the rebuild completes and the short cache expires. |
| 179 |
set_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED, 0, self::STATUS_CACHE_TIMEOUT_SELFHEAL_TTL); |
| 180 |
return 0; |
| 181 |
} |
| 182 |
|
| 183 |
// Non-timeout errors (network blip, replication lag, etc.) return |
| 184 |
// 0 without caching so the next request retries promptly. |
| 185 |
if ($hadError) { |
| 186 |
return 0; |
| 187 |
} |
| 188 |
|
| 189 |
// If the rollup exists but has no rows yet (first run, or rebuild in |
| 190 |
// progress), schedule a rebuild AND skip caching so the next request |
| 191 |
// can serve real data once the rebuild completes (typically seconds). |
| 192 |
if ($count === 0) { |
| 193 |
if ($this->isHitsTableEmpty()) { |
| 194 |
$this->scheduleHitsTableRebuild(); |
| 195 |
return 0; |
| 196 |
} |
| 197 |
} |
| 198 |
|
| 199 |
set_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED, $count, self::STATUS_CACHE_TTL); |
| 200 |
|
| 201 |
return $count; |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* Build the SQL for getHighImpactCapturedCount(). Exposed so structural |
| 206 |
* regression tests can assert no logsv2 access and verify the EXPLAIN plan. |
| 207 |
* |
| 208 |
* @return string Fully-replaced SQL (table-name placeholders resolved). |
| 209 |
*/ |
| 210 |
function buildHighImpactCapturedCountQuery(): string { |
| 211 |
// logs_hits.requested_url is stored in canonical form (leading '/', |
| 212 |
// no trailing '/') by createRedirectsForViewHitsTable(). Match against |
| 213 |
// the persisted r.canonical_url column (added 4.1.10) so the JOIN is |
| 214 |
// an indexed equality lookup instead of CONCAT/TRIM per row. The |
| 215 |
// COALESCE fallback covers rows from upgraded sites where the chunked |
| 216 |
// backfill hasn't reached yet. |
| 217 |
$query = "SELECT COUNT(*) AS cnt |
| 218 |
FROM {wp_abj404_redirects} r |
| 219 |
INNER JOIN {wp_abj404_logs_hits} h |
| 220 |
ON BINARY h.requested_url = BINARY |
| 221 |
COALESCE(r.canonical_url, CONCAT('/', TRIM(BOTH '/' FROM r.url))) |
| 222 |
WHERE r.status = " . ABJ404_STATUS_CAPTURED . " AND r.disabled = 0 |
| 223 |
AND h.logshits >= 3"; |
| 224 |
return $this->doTableNameReplacements($query); |
| 225 |
} |
| 226 |
|
| 227 |
/** |
| 228 |
* Cheap probe: does the logs_hits rollup contain at least one row? |
| 229 |
* SELECT 1 ... LIMIT 1 against a small table. |
| 230 |
* |
| 231 |
* @return bool true when the rollup has zero rows (rebuild in progress, |
| 232 |
* cold start, or post-truncate). false when at least one |
| 233 |
* row exists OR when the probe itself errors (treat |
| 234 |
* ambiguous probes as "not empty" so we don't spam reschedules). |
| 235 |
*/ |
| 236 |
private function isHitsTableEmpty(): bool { |
| 237 |
$check = "SELECT 1 FROM {wp_abj404_logs_hits} LIMIT 1"; |
| 238 |
$check = $this->doTableNameReplacements($check); |
| 239 |
$result = $this->queryAndGetResults($check); |
| 240 |
if (!empty($result['last_error']) || !empty($result['timed_out'])) { |
| 241 |
return false; |
| 242 |
} |
| 243 |
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); |
| 244 |
return empty($rows); |
| 245 |
} |
| 246 |
|
| 247 |
/** |
| 248 |
* Execute a query with a timeout to prevent it from blocking the page indefinitely. |
| 249 |
* On timeout, logs an error with the query shape and returns an empty result. |
| 250 |
* |
| 251 |
* Delegates to queryAndGetResults() with the 'timeout' option, which handles |
| 252 |
* MySQL 5.7+ (MAX_EXECUTION_TIME hint) and MariaDB 10.1+ (max_statement_time). |
| 253 |
* |
| 254 |
* @param string $query The SQL query to execute |
| 255 |
* @param int $timeoutSeconds Maximum execution time in seconds |
| 256 |
* @return array<string, mixed> Same format as queryAndGetResults() |
| 257 |
*/ |
| 258 |
private function queryWithTimeout(string $query, int $timeoutSeconds = 60): array { |
| 259 |
return $this->queryAndGetResults($query, array( |
| 260 |
'timeout' => $timeoutSeconds, |
| 261 |
)); |
| 262 |
} |
| 263 |
|
| 264 |
/** |
| 265 |
* Per-request "bulk mutation in progress" flag. When set, per-row |
| 266 |
* invalidateStatusCountsCache() calls short-circuit to a no-op so a |
| 267 |
* 10K-row CSV import does not fire 60K invalidation queries (each row |
| 268 |
* cascades to bumpMutationWatermark + delete_option + |
| 269 |
* delete_transient × N + DELETE FROM view_cache; for a 10K import |
| 270 |
* this took ~63s before this guard). The bulk caller is responsible |
| 271 |
* for issuing ONE final invalidation (typically via |
| 272 |
* markViewDoneInvalidatedByAdminMutation()) after the bulk write |
| 273 |
* completes, so admin reads see the imported rows immediately. |
| 274 |
* |
| 275 |
* Implemented as a static on the DAO instance ($this only because |
| 276 |
* trait scoping requires it) so the flag survives across multiple |
| 277 |
* setupRedirect() calls within one request without needing every |
| 278 |
* caller to thread a parameter through. |
| 279 |
* |
| 280 |
* @var bool |
| 281 |
*/ |
| 282 |
public static $bulkMutationInProgress = false; |
| 283 |
|
| 284 |
/** |
| 285 |
* Open/close the bulk-mutation window. Bulk importers (CSV import, |
| 286 |
* sitemap regeneration, future bulk admin actions) wrap their per-row |
| 287 |
* loop with this. The callable is invoked while the flag is set; |
| 288 |
* exceptions are rethrown but the flag is always restored. |
| 289 |
* |
| 290 |
* On window close, issues exactly one bumpMutationWatermark() to |
| 291 |
* represent the entire batch as a single mutation tick. Without this |
| 292 |
* the per-row chain bumps are all suppressed and a later |
| 293 |
* markViewDoneInvalidatedByAdminMutation() call would observe the |
| 294 |
* pre-batch counter, leaving the admin-visibility gate un-raised |
| 295 |
* and the next read returning the stale snapshot (the failure mode |
| 296 |
* WpCliMutationEndToEndCharacterizationTest::testCliBulkAddFromCsv |
| 297 |
* pins). The bump fires even when the callable returned early or |
| 298 |
* threw, because the side effect of "we entered a mutation window" |
| 299 |
* is what the watermark documents -- whether downstream rows landed |
| 300 |
* is the caller's concern. |
| 301 |
* |
| 302 |
* @template T |
| 303 |
* @param callable():T $work |
| 304 |
* @return T |
| 305 |
*/ |
| 306 |
public function runWithDeferredInvalidation(callable $work) { |
| 307 |
$prior = self::$bulkMutationInProgress; |
| 308 |
self::$bulkMutationInProgress = true; |
| 309 |
try { |
| 310 |
return $work(); |
| 311 |
} finally { |
| 312 |
self::$bulkMutationInProgress = $prior; |
| 313 |
$this->bumpMutationWatermark(); |
| 314 |
} |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* Invalidate cached status counts. |
| 319 |
* Call this when redirects are created, updated, or deleted. |
| 320 |
* |
| 321 |
* No-op when {@see self::$bulkMutationInProgress} is set; the bulk |
| 322 |
* caller must issue one final invalidation after the loop completes. |
| 323 |
*/ |
| 324 |
/** @return void */ |
| 325 |
function invalidateStatusCountsCache(): void { |
| 326 |
if (self::$bulkMutationInProgress) { |
| 327 |
return; |
| 328 |
} |
| 329 |
delete_transient(self::CACHE_KEY_REDIRECT_STATUS); |
| 330 |
delete_transient(self::CACHE_KEY_CAPTURED_STATUS); |
| 331 |
delete_transient(self::CACHE_KEY_HIGH_IMPACT_CAPTURED); |
| 332 |
$this->invalidateViewSnapshotCache(); |
| 333 |
} |
| 334 |
|
| 335 |
/** |
| 336 |
* Clear the view snapshot cache so the admin redirect/captured tables |
| 337 |
* reflect newly created, updated, trashed, or deleted redirects immediately. |
| 338 |
* |
| 339 |
* This clears both the custom wp_abj404_view_cache table and the |
| 340 |
* WordPress transients used as a secondary cache layer. |
| 341 |
* |
| 342 |
* @return void |
| 343 |
*/ |
| 344 |
function invalidateViewSnapshotCache(): void { |
| 345 |
// Source-mutation signal (Phase 4 of the staged view-build watermark |
| 346 |
// refactor; see docs/refactor-staged-view-build-watermark.md). Every |
| 347 |
// DAO mutator (deleteRedirect, setupRedirect, updateRedirect, |
| 348 |
// updateRedirectTypeStatus, moveRedirectsToTrash, removeDuplicatesCron, |
| 349 |
// purgeRedirectsByStatus) routes through invalidateStatusCountsCache() |
| 350 |
// -> here. Bump the per-blog mutation watermark so the staged-build |
| 351 |
// runner observes it at the next stage boundary and either aborts |
| 352 |
// the in-flight build cleanly (so the next build covers the new row) |
| 353 |
// or, if the build is already running for an earlier watermark, the |
| 354 |
// active_build_started_watermark gate keeps the runner from |
| 355 |
// publishing a snapshot that misses the mutation. |
| 356 |
$this->bumpMutationWatermark(); |
| 357 |
|
| 358 |
// Clear view_done freshness so the read path's TTL check trips and |
| 359 |
// a rebuild gets scheduled on the next request. The runner remains |
| 360 |
// the sole owner of progress markers, the S1 prefix capture, and |
| 361 |
// the transient buffer tables: external code (this seam included) |
| 362 |
// must not touch them. scheduleViewDoneRebuild() is idempotent |
| 363 |
// (wp_next_scheduled short-circuit) so concurrent mutators do not |
| 364 |
// pile up cron events. |
| 365 |
if (function_exists('delete_option')) { |
| 366 |
delete_option($this->viewDoneFreshnessOptionName()); |
| 367 |
} |
| 368 |
$this->invalidateViewDoneServeableCache(); |
| 369 |
$this->scheduleViewDoneRebuild(); |
| 370 |
|
| 371 |
// Clear all rows from the view cache table. log_errors=false marks |
| 372 |
// this as a best-effort operation (the cache expires naturally via |
| 373 |
// TTL if the DELETE fails). skip_repair=true blocks the missing- |
| 374 |
// table auto-create + retry path: a missing view_cache means |
| 375 |
// "nothing to invalidate"; spinning up the full createDatabaseTables |
| 376 |
// flow to make the DELETE succeed is wasteful in production and in |
| 377 |
// tests it cascades correctCollations -> bumpMutationWatermark, which |
| 378 |
// breaks the "exactly one bump per source-data mutation" contract |
| 379 |
// pinned by MixedSourceConcurrentMutationIntegrationTest. |
| 380 |
$query = "DELETE FROM {wp_abj404_view_cache} WHERE 1=1"; |
| 381 |
$this->queryAndGetResults($query, array('log_errors' => false, 'skip_repair' => true)); |
| 382 |
|
| 383 |
// Clear WordPress transients for view row and count snapshots. |
| 384 |
// The transient keys are hashed (e.g. abj404_view_rows_<md5>), so |
| 385 |
// we delete by prefix from wp_options directly. |
| 386 |
global $wpdb; |
| 387 |
if (isset($wpdb->options) && method_exists($wpdb, 'query')) { |
| 388 |
// @utf8-audit: opt-out — $wpdb->options is the WordPress core |
| 389 |
// options table name (system value); never user input. |
| 390 |
/** @var string $optionsTable */ |
| 391 |
$optionsTable = esc_sql($wpdb->options); |
| 392 |
// DAO-bypass-approved: View-cache clear targets wp_options — outside the plugin's owned tables; runs during cache invalidation hot path; failure is best-effort |
| 393 |
$wpdb->query( |
| 394 |
"DELETE FROM `{$optionsTable}` WHERE option_name LIKE '_transient_abj404_view_%'" |
| 395 |
. " OR option_name LIKE '_transient_timeout_abj404_view_%'" |
| 396 |
); |
| 397 |
} |
| 398 |
} |
| 399 |
|
| 400 |
/** |
| 401 |
* Clear the per-request regex redirects cache. |
| 402 |
* Primarily used for testing. In production, the cache resets automatically |
| 403 |
* on each new request since it uses static variables. |
| 404 |
*/ |
| 405 |
/** @return void */ |
| 406 |
function clearRegexRedirectsCache(): void { |
| 407 |
self::$regexRedirectsCache = null; |
| 408 |
self::$regexCacheDisabled = false; |
| 409 |
} |
| 410 |
|
| 411 |
/** |
| 412 |
* @global type $wpdb |
| 413 |
* @param int $logID only return results that correspond to the URL of this $logID. Use 0 to get all records. |
| 414 |
* @return int the number of records found. |
| 415 |
*/ |
| 416 |
function getLogsCount($logID) { |
| 417 |
// Sanitize logID to prevent SQL injection |
| 418 |
$logID = absint($logID); |
| 419 |
|
| 420 |
// Audit F4: cache the unfiltered total. InnoDB has no maintained row |
| 421 |
// counter so `SELECT COUNT(id) FROM logsv2` is a full index scan that |
| 422 |
// dominates the Logs admin tab on multi-million-row logsv2. The cache |
| 423 |
// is keyed on (blog_id, max_log_id) so new inserts move the key |
| 424 |
// (fresh value picked up immediately); deletions are bounded by the |
| 425 |
// LOGS_COUNT_CACHE_TTL_SECONDS staleness window. The filtered path |
| 426 |
// (logID != 0) is per-URL and has unbounded key cardinality, so it |
| 427 |
// stays uncached. |
| 428 |
$cacheKey = null; |
| 429 |
if ($logID === 0 && function_exists('get_transient')) { |
| 430 |
$blogId = 1; |
| 431 |
if (function_exists('get_current_blog_id')) { |
| 432 |
$rawBlogId = function_exists('absint') |
| 433 |
? absint(get_current_blog_id()) |
| 434 |
: abs(intval(get_current_blog_id())); |
| 435 |
if ($rawBlogId > 0) { |
| 436 |
$blogId = $rawBlogId; |
| 437 |
} |
| 438 |
} |
| 439 |
$maxLogId = 0; |
| 440 |
try { |
| 441 |
$maxLogId = intval($this->getMaxLogId()); |
| 442 |
if ($maxLogId < 0) { |
| 443 |
$maxLogId = 0; |
| 444 |
} |
| 445 |
} catch (Throwable $e) { |
| 446 |
// getMaxLogId() failed (table missing, query timeout). Fall back |
| 447 |
// to maxLogId=0 so the cache key still varies; the count will |
| 448 |
// recompute on every request until the underlying query recovers. |
| 449 |
$this->logger->debugMessage(__FUNCTION__ . ' getMaxLogId() failed: ' |
| 450 |
. $e->getMessage() . '. Falling back to maxLogId=0.'); |
| 451 |
$maxLogId = 0; |
| 452 |
} |
| 453 |
$cacheKey = 'abj404_logs_count_v1_' . $blogId . '_' . $maxLogId; |
| 454 |
$cached = get_transient($cacheKey); |
| 455 |
if (is_numeric($cached)) { |
| 456 |
return (int)$cached; |
| 457 |
} |
| 458 |
} |
| 459 |
|
| 460 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getLogsCount.sql"); |
| 461 |
|
| 462 |
if ($logID != 0) { |
| 463 |
$query = $this->f->str_replace('/* {SPECIFIC_ID}', '', $query); |
| 464 |
$query = $this->f->str_replace('{logID}', (string)$logID, $query); |
| 465 |
} |
| 466 |
|
| 467 |
// Route through queryAndGetResults() so the count query (potentially |
| 468 |
// a JOIN against logsv2 when SPECIFIC_ID is set) inherits the |
| 469 |
// centralized 60s timeout. Bypassing via $wpdb->get_row() leaves the |
| 470 |
// admin page with no upper bound on slow logsv2 lookups. |
| 471 |
$result = $this->queryAndGetResults($query); |
| 472 |
$hadError = !empty($result['timed_out']) |
| 473 |
|| (isset($result['last_error']) && $result['last_error'] != ''); |
| 474 |
|
| 475 |
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); |
| 476 |
$count = 0; |
| 477 |
if (!empty($rows)) { |
| 478 |
$first = $rows[0]; |
| 479 |
$value = is_array($first) ? reset($first) : $first; |
| 480 |
$count = intval($value); |
| 481 |
} |
| 482 |
|
| 483 |
// Only cache on success. A DB error / timeout would otherwise pin a |
| 484 |
// zero for LOGS_COUNT_CACHE_TTL_SECONDS, so the Logs admin tab would |
| 485 |
// show "0 entries" until the cache expires. Mirrors the |
| 486 |
// getDailyActivityTrend() write-on-success policy. |
| 487 |
if (!$hadError && $cacheKey !== null && function_exists('set_transient')) { |
| 488 |
set_transient($cacheKey, $count, self::LOGS_COUNT_CACHE_TTL_SECONDS); |
| 489 |
} |
| 490 |
|
| 491 |
return $count; |
| 492 |
} |
| 493 |
|
| 494 |
/** |
| 495 |
* @return array<int, array<string, mixed>> |
| 496 |
*/ |
| 497 |
function getRedirectsAll() { |
| 498 |
$query = "select id, url from {wp_abj404_redirects} order by url"; |
| 499 |
|
| 500 |
// Route through queryAndGetResults() so this list query inherits the |
| 501 |
// centralized 60s timeout. The redirects table can be very large on |
| 502 |
// busy sites and an unbounded ORDER BY without timeout protection |
| 503 |
// could exceed reverse-proxy limits. |
| 504 |
$result = $this->queryAndGetResults($query); |
| 505 |
if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) { |
| 506 |
return array(); |
| 507 |
} |
| 508 |
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); |
| 509 |
return $rows; |
| 510 |
} |
| 511 |
|
| 512 |
/** @param string $tempFile @return void */ |
| 513 |
function doRedirectsExport(string $tempFile): void { |
| 514 |
global $wpdb; |
| 515 |
|
| 516 |
if (file_exists($tempFile)) { |
| 517 |
ABJ_404_Solution_Functions::safeUnlink($tempFile); |
| 518 |
} |
| 519 |
|
| 520 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . |
| 521 |
"/sql/getRedirectsExport.sql"); |
| 522 |
$query = $this->doTableNameReplacements($query); |
| 523 |
|
| 524 |
// we use mysqli here instead of the normal wordpress get_results in order |
| 525 |
// to get one row at a time, so we don't run out of memory by trying to store |
| 526 |
// everything in memory all at once. |
| 527 |
$result = mysqli_query($wpdb->dbh, $query); |
| 528 |
if ($result instanceof \mysqli_result) { |
| 529 |
$fh = fopen($tempFile, 'w'); |
| 530 |
if ($fh === false) { |
| 531 |
return; |
| 532 |
} |
| 533 |
fputcsv($fh, array('from_url', 'status', 'type', 'to_url', 'wp_type', 'engine', 'code'), ',', '"', '\\'); |
| 534 |
|
| 535 |
while (($row = mysqli_fetch_array($result, MYSQLI_ASSOC))) { |
| 536 |
fputcsv($fh, array( |
| 537 |
$row['from_url'], |
| 538 |
$row['status'], |
| 539 |
$row['type'], |
| 540 |
$row['to_url'], |
| 541 |
$row['type_wp'], |
| 542 |
isset($row['engine']) ? $row['engine'] : '', |
| 543 |
isset($row['code']) ? $row['code'] : '301' |
| 544 |
), ',', '"', '\\'); |
| 545 |
} |
| 546 |
fclose($fh); |
| 547 |
mysqli_free_result($result); |
| 548 |
} |
| 549 |
} |
| 550 |
|
| 551 |
/** Only return redirects that have a log entry. |
| 552 |
* @global type $wpdb |
| 553 |
* @global type $abj404dao |
| 554 |
* @return array<int, array<string, mixed>> |
| 555 |
*/ |
| 556 |
function getRedirectsWithLogs() { |
| 557 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getRedirectsWithLogs.sql"); |
| 558 |
|
| 559 |
// Route through queryAndGetResults() so this redirects+logs JOIN |
| 560 |
// inherits the centralized 60s timeout. logsv2 can be huge, and the |
| 561 |
// join shape is identical to the one already protected in the hits |
| 562 |
// table rebuild path (commit 70f3b5fe). |
| 563 |
$result = $this->queryAndGetResults($query); |
| 564 |
if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) { |
| 565 |
return array(); |
| 566 |
} |
| 567 |
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : array(); |
| 568 |
return $rows; |
| 569 |
} |
| 570 |
|
| 571 |
/** |
| 572 |
* Get all regex redirects for pattern matching. |
| 573 |
* Uses per-request caching when redirect count is <= 50 to avoid repeated queries. |
| 574 |
* Cache is automatically skipped if there are too many regex redirects (memory guard). |
| 575 |
* |
| 576 |
* @return array<int, array<string, mixed>> |
| 577 |
*/ |
| 578 |
function getRedirectsWithRegEx() { |
| 579 |
// Return cached results if available (and caching wasn't disabled due to count) |
| 580 |
if (self::$regexRedirectsCache !== null && !self::$regexCacheDisabled) { |
| 581 |
return self::$regexRedirectsCache; |
| 582 |
} |
| 583 |
|
| 584 |
// If caching was disabled due to too many redirects, just query without caching |
| 585 |
if (self::$regexCacheDisabled) { |
| 586 |
return $this->queryRegexRedirects(); |
| 587 |
} |
| 588 |
|
| 589 |
// First query - check count and decide whether to cache |
| 590 |
$results = $this->queryRegexRedirects(); |
| 591 |
|
| 592 |
// Only cache if count is within safe memory limits |
| 593 |
if (count($results) <= self::REGEX_CACHE_MAX_COUNT) { |
| 594 |
self::$regexRedirectsCache = $results; |
| 595 |
} else { |
| 596 |
// Too many regex redirects - disable caching for this request |
| 597 |
self::$regexCacheDisabled = true; |
| 598 |
} |
| 599 |
|
| 600 |
return $results; |
| 601 |
} |
| 602 |
|
| 603 |
/** |
| 604 |
* Execute the regex redirects query. |
| 605 |
* Separated from getRedirectsWithRegEx() for cache logic clarity. |
| 606 |
* |
| 607 |
* @return array<int, array<string, mixed>> |
| 608 |
*/ |
| 609 |
private function queryRegexRedirects() { |
| 610 |
$query = "select \n {wp_abj404_redirects}.id,\n {wp_abj404_redirects}.url,\n {wp_abj404_redirects}.status,\n" |
| 611 |
. " {wp_abj404_redirects}.type,\n {wp_abj404_redirects}.final_dest,\n {wp_abj404_redirects}.code,\n" |
| 612 |
. " {wp_abj404_redirects}.timestamp,\n {wp_posts}.id as wp_post_id\n "; |
| 613 |
$query .= "from {wp_abj404_redirects}\n " . |
| 614 |
" LEFT OUTER JOIN {wp_posts} \n " . |
| 615 |
" on {wp_abj404_redirects}.final_dest = {wp_posts}.id \n "; |
| 616 |
|
| 617 |
$query .= "where status in (" . ABJ404_STATUS_REGEX . ") \n " . |
| 618 |
" and disabled = 0"; |
| 619 |
$results = $this->queryAndGetResults($query); |
| 620 |
|
| 621 |
/** @var array<int, array<string, mixed>> $rows */ |
| 622 |
$rows = is_array($results['rows']) ? $results['rows'] : array(); |
| 623 |
return $rows; |
| 624 |
} |
| 625 |
|
| 626 |
/** |
| 627 |
* Find MANUAL redirects whose `url` column contains an unambiguous |
| 628 |
* regex metacharacter (`* [ ] | ^ \ { }`). These are rows the admin |
| 629 |
* created via a pre-auto-promote path (older plugin version, direct |
| 630 |
* DB write, CSV import before the 4.1.x sniff was widened) that |
| 631 |
* really should be treated as regex. The runtime fallback in |
| 632 |
* SpellCheckerTrait_URLMatching tries them as regex without |
| 633 |
* mutating the stored status; the auto-promote on next save sweeps |
| 634 |
* them into the regular regex query. |
| 635 |
* |
| 636 |
* The LIKE filter is deliberately broad to keep the query simple; |
| 637 |
* the runtime caller re-checks with the precise PHP-side helper |
| 638 |
* (looksLikeUnambiguousRegex) before treating any row as regex. |
| 639 |
* |
| 640 |
* @return array<int, array<string, mixed>> |
| 641 |
*/ |
| 642 |
function getManualRedirectsWithRegexMetachars() { |
| 643 |
$query = "select \n {wp_abj404_redirects}.id,\n {wp_abj404_redirects}.url,\n {wp_abj404_redirects}.status,\n" |
| 644 |
. " {wp_abj404_redirects}.type,\n {wp_abj404_redirects}.final_dest,\n {wp_abj404_redirects}.code,\n" |
| 645 |
. " {wp_abj404_redirects}.timestamp,\n {wp_posts}.id as wp_post_id\n "; |
| 646 |
$query .= "from {wp_abj404_redirects}\n " . |
| 647 |
" LEFT OUTER JOIN {wp_posts} \n " . |
| 648 |
" on {wp_abj404_redirects}.final_dest = {wp_posts}.id \n "; |
| 649 |
|
| 650 |
// SQL-side prefilter using INSTR per metachar. INSTR avoids LIKE's |
| 651 |
// wildcard/escape semantics so we do not have to special-case |
| 652 |
// the backslash byte. The PHP-side caller re-checks each row with |
| 653 |
// looksLikeUnambiguousRegex(), so a few false positives here |
| 654 |
// are harmless; the goal is to never miss a row that should be |
| 655 |
// considered. Set matches the helper class. |
| 656 |
$query .= "where status = " . ABJ404_STATUS_MANUAL . " \n " . |
| 657 |
" and disabled = 0 \n " . |
| 658 |
" and (INSTR(`url`, '*') > 0 " . |
| 659 |
" OR INSTR(`url`, '[') > 0 " . |
| 660 |
" OR INSTR(`url`, ']') > 0 " . |
| 661 |
" OR INSTR(`url`, '|') > 0 " . |
| 662 |
" OR INSTR(`url`, '^') > 0 " . |
| 663 |
" OR INSTR(`url`, '\\\\') > 0 " . |
| 664 |
" OR INSTR(`url`, '{') > 0 " . |
| 665 |
" OR INSTR(`url`, '}') > 0)"; |
| 666 |
$results = $this->queryAndGetResults($query); |
| 667 |
|
| 668 |
/** @var array<int, array<string, mixed>> $rows */ |
| 669 |
$rows = is_array($results['rows']) ? $results['rows'] : array(); |
| 670 |
return $rows; |
| 671 |
} |
| 672 |
|
| 673 |
/** Returns the redirects that are in place. |
| 674 |
* @global type $wpdb |
| 675 |
* @param string $sub either "redirects" or "captured". |
| 676 |
* @param array<string, mixed> $tableOptions filter, order by, paged, perpage etc. |
| 677 |
* @return array<int|string, mixed> rows from the redirects table. |
| 678 |
*/ |
| 679 |
function getRedirectsForView($sub, $tableOptions) { |
| 680 |
$canUseSnapshotCache = $this->canUseViewTableSnapshotCache($tableOptions); |
| 681 |
$queryTimeout = isset($tableOptions['_abj404_query_timeout']) && is_numeric($tableOptions['_abj404_query_timeout']) |
| 682 |
? max(1, intval($tableOptions['_abj404_query_timeout'])) : 0; |
| 683 |
$throwOnQueryError = !empty($tableOptions['_abj404_throw_on_view_query_error']); |
| 684 |
$snapshotCacheKey = ''; |
| 685 |
if ($canUseSnapshotCache && $queryTimeout <= 0) { |
| 686 |
$snapshotCacheKey = $this->getViewSnapshotCacheKey('abj404_view_rows', $sub, $tableOptions); |
| 687 |
$cachedRowsFromTable = $this->getViewRowsSnapshotFromTable($snapshotCacheKey, false, false); |
| 688 |
if (is_array($cachedRowsFromTable)) { |
| 689 |
return $cachedRowsFromTable; |
| 690 |
} |
| 691 |
if (function_exists('get_transient')) { |
| 692 |
$cachedRows = get_transient($snapshotCacheKey); |
| 693 |
if (is_array($cachedRows)) { |
| 694 |
return $cachedRows; |
| 695 |
} |
| 696 |
} |
| 697 |
} |
| 698 |
|
| 699 |
try { |
| 700 |
$rows = $this->runRedirectsForViewStaged((string)$sub, is_array($tableOptions) ? $tableOptions : array()); |
| 701 |
} catch (ABJ_404_Solution_ViewBuildPendingException $pending) { |
| 702 |
// Cold-start state, not an error. The fetch AJAX gate normally |
| 703 |
// intercepts this before the read; non-AJAX callers (REST, warmup) |
| 704 |
// see an empty page and retry once cron / the JS poller advances |
| 705 |
// the build. Re-throw when the warmup pipeline asks for it so its |
| 706 |
// attempt counter advances and a stage gets blamed. |
| 707 |
if ($throwOnQueryError) { |
| 708 |
throw $pending; |
| 709 |
} |
| 710 |
$this->logger->debugMessage('[staged] getRedirectsForView pending: ' . $pending->getMessage()); |
| 711 |
return array(); |
| 712 |
} catch (Throwable $e) { |
| 713 |
if ($throwOnQueryError) { |
| 714 |
$stagedFailureMarker = '/* staged: ' . $e->getMessage() . ' */'; |
| 715 |
$diagnostics = $this->captureViewQueryFailureDiagnostics( |
| 716 |
(string)$sub, |
| 717 |
$stagedFailureMarker, |
| 718 |
is_array($tableOptions) ? $tableOptions : array(), |
| 719 |
array('last_error' => $e->getMessage(), 'timed_out' => false) |
| 720 |
); |
| 721 |
$diagnostics['failed_query_label'] = 'getRedirectsForView'; |
| 722 |
$diagnostics['staged_error'] = $e->getMessage(); |
| 723 |
$message = 'getRedirectsForView failed; last_error=' . $e->getMessage() |
| 724 |
. '; timed_out=false; sql_source=' . $stagedFailureMarker; |
| 725 |
throw new ABJ_404_Solution_ViewQueryFailureException($message, $diagnostics); |
| 726 |
} |
| 727 |
$this->logger->errorMessage('[staged] getRedirectsForView failed: ' . $e->getMessage(), |
| 728 |
$e instanceof \Exception ? $e : null); |
| 729 |
return array(); |
| 730 |
} |
| 731 |
|
| 732 |
$this->logger->debugMessage(sprintf( |
| 733 |
'[staged] getRedirectsForView returned %d rows for page %s', |
| 734 |
count($rows), |
| 735 |
(string)$sub |
| 736 |
)); |
| 737 |
|
| 738 |
if ($canUseSnapshotCache && $snapshotCacheKey === '') { |
| 739 |
$snapshotCacheKey = $this->getViewSnapshotCacheKey('abj404_view_rows', $sub, $tableOptions); |
| 740 |
} |
| 741 |
if ($canUseSnapshotCache && $snapshotCacheKey !== '') { |
| 742 |
$this->setViewRowsSnapshotToTable($snapshotCacheKey, $sub, $rows, self::VIEW_SNAPSHOT_CACHE_TTL_SECONDS); |
| 743 |
if (function_exists('set_transient')) { |
| 744 |
// allow-cache-empty: empty $rows is a legitimate result on a fresh install (no redirects yet); error paths early-return above without reaching this line |
| 745 |
set_transient($snapshotCacheKey, $rows, self::VIEW_SNAPSHOT_CACHE_TTL_SECONDS); |
| 746 |
} |
| 747 |
} |
| 748 |
|
| 749 |
return $rows; |
| 750 |
} |
| 751 |
|
| 752 |
/** |
| 753 |
* Return whether the admin rows view already has a usable snapshot. |
| 754 |
* |
| 755 |
* Used by the AJAX first-paint path to avoid running an expensive cold |
| 756 |
* table query inline. Fresh snapshots are preferred, but a recently |
| 757 |
* refreshed stale snapshot is still usable because it lets the admin see |
| 758 |
* real rows while background refresh detects newer data non-destructively. |
| 759 |
* |
| 760 |
* @param string $sub |
| 761 |
* @param array<string, mixed> $tableOptions |
| 762 |
* @return bool |
| 763 |
*/ |
| 764 |
function viewRowsSnapshotAvailable($sub, array $tableOptions): bool { |
| 765 |
$canUseSnapshotCache = $this->canUseViewTableSnapshotCache($tableOptions); |
| 766 |
if (!$canUseSnapshotCache) { |
| 767 |
return false; |
| 768 |
} |
| 769 |
|
| 770 |
$snapshotCacheKey = $this->getViewSnapshotCacheKey('abj404_view_rows', $sub, $tableOptions); |
| 771 |
$freshRows = $this->getViewRowsSnapshotFromTable($snapshotCacheKey, false, false); |
| 772 |
if (is_array($freshRows)) { |
| 773 |
return true; |
| 774 |
} |
| 775 |
$recentRows = $this->getViewRowsSnapshotFromTable($snapshotCacheKey, true, true); |
| 776 |
if (is_array($recentRows)) { |
| 777 |
return true; |
| 778 |
} |
| 779 |
if (function_exists('get_transient')) { |
| 780 |
$transientRows = get_transient($snapshotCacheKey); |
| 781 |
if (is_array($transientRows)) { |
| 782 |
return true; |
| 783 |
} |
| 784 |
} |
| 785 |
|
| 786 |
return false; |
| 787 |
} |
| 788 |
|
| 789 |
/** |
| 790 |
* Return whether the full AJAX table response can be rendered from cache. |
| 791 |
* |
| 792 |
* Rows alone are not enough for first paint: pagination rendering also |
| 793 |
* needs getRedirectsForViewCount(). If the count snapshot is cold, the |
| 794 |
* "cached" path can still block on a heavy COUNT query. The initial AJAX |
| 795 |
* cache gate uses this method so cold counts are also pushed to the |
| 796 |
* background hydrate request. |
| 797 |
* |
| 798 |
* @param string $sub |
| 799 |
* @param array<string, mixed> $tableOptions |
| 800 |
* @return bool |
| 801 |
*/ |
| 802 |
function viewTableSnapshotAvailable($sub, array $tableOptions): bool { |
| 803 |
if (!$this->viewRowsSnapshotAvailable($sub, $tableOptions)) { |
| 804 |
return false; |
| 805 |
} |
| 806 |
|
| 807 |
$canUseSnapshotCache = function_exists('get_transient') |
| 808 |
&& $this->canUseViewTableSnapshotCache($tableOptions); |
| 809 |
if (!$canUseSnapshotCache) { |
| 810 |
return false; |
| 811 |
} |
| 812 |
|
| 813 |
$countCacheKey = $this->getViewSnapshotCacheKey('abj404_view_count', $sub, $tableOptions); |
| 814 |
return get_transient($countCacheKey) !== false; |
| 815 |
} |
| 816 |
|
| 817 |
/** |
| 818 |
* @param string $sub |
| 819 |
* @param array<string, mixed> $tableOptions |
| 820 |
* @return int |
| 821 |
*/ |
| 822 |
function getRedirectsForViewCount(string $sub, array $tableOptions): int { |
| 823 |
$queryTimeout = isset($tableOptions['_abj404_query_timeout']) && is_numeric($tableOptions['_abj404_query_timeout']) |
| 824 |
? max(1, intval($tableOptions['_abj404_query_timeout'])) : 0; |
| 825 |
$throwOnQueryError = !empty($tableOptions['_abj404_throw_on_view_query_error']); |
| 826 |
$canUseSnapshotCache = function_exists('get_transient') |
| 827 |
&& $this->canUseViewTableSnapshotCache($tableOptions); |
| 828 |
$requestCountCacheKey = (string)$sub . '|' . md5(serialize($tableOptions)); |
| 829 |
$countCacheKey = ''; |
| 830 |
if ($canUseSnapshotCache && $queryTimeout <= 0) { |
| 831 |
$countCacheKey = $this->getViewSnapshotCacheKey('abj404_view_count', $sub, $tableOptions); |
| 832 |
$cachedCount = get_transient($countCacheKey); |
| 833 |
if ($cachedCount !== false) { |
| 834 |
return intval(is_scalar($cachedCount) ? $cachedCount : 0); |
| 835 |
} |
| 836 |
} |
| 837 |
if (array_key_exists($requestCountCacheKey, $this->redirectsForViewCountRequestCache)) { |
| 838 |
return intval($this->redirectsForViewCountRequestCache[$requestCountCacheKey]); |
| 839 |
} |
| 840 |
|
| 841 |
$rawFilterText = is_string($tableOptions['filterText'] ?? null) ? $tableOptions['filterText'] : ''; |
| 842 |
if ($rawFilterText === '') { |
| 843 |
// No search filter: simple COUNT against the live redirects table |
| 844 |
// is fast enough that there is no value building view_done just |
| 845 |
// for this. Keeps cold-start counts cheap. |
| 846 |
$query = $this->getOptimizedRedirectsForViewCountQuery($sub, $tableOptions); |
| 847 |
$this->setSqlBigSelects(); |
| 848 |
$queryOptions = $queryTimeout > 0 ? array('timeout' => $queryTimeout) : array(); |
| 849 |
$results = $this->queryAndGetResults($query, $queryOptions); |
| 850 |
$lastErrorRaw = $results['last_error'] ?? ''; |
| 851 |
$lastError = is_string($lastErrorRaw) ? $lastErrorRaw : ''; |
| 852 |
} else { |
| 853 |
// Search-filtered count needs to apply the LIKE composite against |
| 854 |
// the precomputed dest_for_view/status_for_view/type_for_view |
| 855 |
// columns, so route through the staged path. |
| 856 |
try { |
| 857 |
$countValue = $this->runRedirectsForViewCountStaged((string)$sub, $tableOptions); |
| 858 |
$this->redirectsForViewCountRequestCache[$requestCountCacheKey] = $countValue; |
| 859 |
if ($canUseSnapshotCache && $countCacheKey === '') { |
| 860 |
$countCacheKey = $this->getViewSnapshotCacheKey('abj404_view_count', $sub, $tableOptions); |
| 861 |
} |
| 862 |
if ($canUseSnapshotCache && $countCacheKey !== '') { |
| 863 |
// allow-cache-empty: $countValue=0 is a legitimate result when no rows match the search filter; the staged pending/error paths throw above without reaching this line |
| 864 |
set_transient($countCacheKey, $countValue, self::VIEW_SNAPSHOT_CACHE_TTL_SECONDS); |
| 865 |
} |
| 866 |
return $countValue; |
| 867 |
} catch (ABJ_404_Solution_ViewBuildPendingException $pending) { |
| 868 |
// view_done not yet built. Same treatment as getRedirectsForView: |
| 869 |
// signal pending up the warmup pipeline if requested, otherwise |
| 870 |
// record a sentinel count and let the caller retry next request. |
| 871 |
if ($throwOnQueryError) { |
| 872 |
throw $pending; |
| 873 |
} |
| 874 |
$this->logger->debugMessage('[staged] getRedirectsForViewCount pending: ' . $pending->getMessage()); |
| 875 |
$this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1; |
| 876 |
return -1; |
| 877 |
} catch (Throwable $e) { |
| 878 |
if ($throwOnQueryError) { |
| 879 |
$stagedFailureMarker = '/* staged-count: ' . $e->getMessage() . ' */'; |
| 880 |
$diagnostics = $this->captureViewQueryFailureDiagnostics( |
| 881 |
(string)$sub, |
| 882 |
$stagedFailureMarker, |
| 883 |
$tableOptions, |
| 884 |
array('last_error' => $e->getMessage(), 'timed_out' => false) |
| 885 |
); |
| 886 |
$diagnostics['failed_query_label'] = 'getRedirectsForViewCount'; |
| 887 |
$diagnostics['staged_error'] = $e->getMessage(); |
| 888 |
throw new ABJ_404_Solution_ViewQueryFailureException($e->getMessage(), $diagnostics); |
| 889 |
} |
| 890 |
$this->logger->errorMessage('[staged] getRedirectsForViewCount failed: ' . $e->getMessage(), |
| 891 |
$e instanceof \Exception ? $e : null); |
| 892 |
$this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1; |
| 893 |
return -1; |
| 894 |
} |
| 895 |
} |
| 896 |
|
| 897 |
if ($throwOnQueryError && (!empty($results['timed_out']) || $lastError !== '')) { |
| 898 |
$message = $this->formatViewQueryFailureMessage('getRedirectsForViewCount', $query, $results); |
| 899 |
$diagnostics = $this->captureViewQueryFailureDiagnostics($sub, $query, $tableOptions, $results); |
| 900 |
$diagnostics['failed_query_label'] = 'getRedirectsForViewCount'; |
| 901 |
throw new ABJ_404_Solution_ViewQueryFailureException($message, $diagnostics); |
| 902 |
} |
| 903 |
|
| 904 |
if ($lastError != '' && trim($lastError) != '') { |
| 905 |
$diagnostics = $this->captureViewQueryFailureDiagnostics($sub, $query, $tableOptions, $results); |
| 906 |
$diagnostics['failed_query_label'] = 'getRedirectsForViewCount'; |
| 907 |
throw new ABJ_404_Solution_ViewQueryFailureException( |
| 908 |
"Error getting redirect count: " . esc_html($lastError), |
| 909 |
$diagnostics |
| 910 |
); |
| 911 |
} |
| 912 |
$rows = is_array($results['rows']) ? $results['rows'] : array(); |
| 913 |
if (empty($rows)) { |
| 914 |
$this->redirectsForViewCountRequestCache[$requestCountCacheKey] = -1; |
| 915 |
return -1; |
| 916 |
} |
| 917 |
$row = is_array($rows[0] ?? null) ? $rows[0] : array(); |
| 918 |
$rawCount = $row['count'] ?? $row['COUNT(*)'] ?? reset($row); |
| 919 |
$countValue = intval(is_scalar($rawCount) ? $rawCount : 0); |
| 920 |
$this->redirectsForViewCountRequestCache[$requestCountCacheKey] = $countValue; |
| 921 |
if ($canUseSnapshotCache && $countCacheKey === '') { |
| 922 |
$countCacheKey = $this->getViewSnapshotCacheKey('abj404_view_count', $sub, $tableOptions); |
| 923 |
} |
| 924 |
if ($canUseSnapshotCache && $countCacheKey !== '') { |
| 925 |
set_transient($countCacheKey, $countValue, self::VIEW_SNAPSHOT_CACHE_TTL_SECONDS); |
| 926 |
} |
| 927 |
return $countValue; |
| 928 |
} |
| 929 |
|
| 930 |
/** |
| 931 |
* @param string $sub |
| 932 |
* @param array<string, mixed> $tableOptions |
| 933 |
* @return string |
| 934 |
*/ |
| 935 |
private function getOptimizedRedirectsForViewCountQuery(string $sub, array $tableOptions): string { |
| 936 |
global $abj404_redirect_types, $abj404_captured_types; |
| 937 |
|
| 938 |
$statusTypes = ''; |
| 939 |
if ($tableOptions['filter'] == 0 || $tableOptions['filter'] == ABJ404_TRASH_FILTER) { |
| 940 |
if ($sub == 'abj404_redirects') { |
| 941 |
$statusTypes = implode(", ", $abj404_redirect_types); |
| 942 |
} else if ($sub == 'abj404_captured') { |
| 943 |
$statusTypes = implode(", ", $abj404_captured_types); |
| 944 |
} |
| 945 |
} else if ($tableOptions['filter'] == ABJ404_STATUS_MANUAL) { |
| 946 |
$statusTypes = implode(", ", array(ABJ404_STATUS_MANUAL, ABJ404_STATUS_REGEX)); |
| 947 |
} else if ($tableOptions['filter'] == ABJ404_HANDLED_FILTER) { |
| 948 |
$statusTypes = implode(", ", array(ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER)); |
| 949 |
} else { |
| 950 |
$statusTypes = $tableOptions['filter']; |
| 951 |
} |
| 952 |
$statusTypes = preg_replace('/[^\d, ]/', '', trim(is_string($statusTypes) ? $statusTypes : '')); |
| 953 |
|
| 954 |
$trashValue = ($tableOptions['filter'] == ABJ404_TRASH_FILTER) ? 1 : 0; |
| 955 |
|
| 956 |
$scoreRangeClause = ''; |
| 957 |
$rawScoreRange = is_string($tableOptions['score_range'] ?? '') ? ($tableOptions['score_range'] ?? 'all') : 'all'; |
| 958 |
// Each `wp_abj404_redirects.*` reference below is the SQL alias bound by the |
| 959 |
// `FROM {wp_abj404_redirects} wp_abj404_redirects` clause in the assembled |
| 960 |
// query, not a hardcoded table-name literal. Per-line markers keep the |
| 961 |
// lint window (+/- 1 line) honest. |
| 962 |
switch ($rawScoreRange) { |
| 963 |
case 'high': $scoreRangeClause = 'AND wp_abj404_redirects.score >= 80'; break; // allow-prefix-literal: SQL alias, see comment above |
| 964 |
case 'medium': $scoreRangeClause = 'AND wp_abj404_redirects.score >= 50 AND wp_abj404_redirects.score < 80'; break; // allow-prefix-literal: SQL alias |
| 965 |
case 'low': $scoreRangeClause = 'AND wp_abj404_redirects.score IS NOT NULL AND wp_abj404_redirects.score < 50'; break; // allow-prefix-literal: SQL alias |
| 966 |
case 'manual': $scoreRangeClause = 'AND wp_abj404_redirects.score IS NULL'; break; // allow-prefix-literal: SQL alias |
| 967 |
} |
| 968 |
|
| 969 |
$query = "SELECT COUNT(*) AS count\n" . |
| 970 |
"FROM {wp_abj404_redirects} wp_abj404_redirects\n" . // allow-prefix-literal: second token is the SQL alias name, not a table reference |
| 971 |
"WHERE 1 and status IN (" . $statusTypes . ") AND disabled = " . intval($trashValue) . "\n" . |
| 972 |
$scoreRangeClause; |
| 973 |
|
| 974 |
return $this->doTableNameReplacements($query); |
| 975 |
} |
| 976 |
|
| 977 |
/** |
| 978 |
* @param string $sub |
| 979 |
* @param array<string, mixed> $tableOptions |
| 980 |
* @param bool $queryAllRowsAtOnce |
| 981 |
* @param int $limitStart |
| 982 |
* @param int $limitEnd |
| 983 |
* @param bool $selectCountOnly |
| 984 |
* @return string |
| 985 |
*/ |
| 986 |
function getRedirectsForViewQuery($sub, $tableOptions, $queryAllRowsAtOnce, |
| 987 |
$limitStart, $limitEnd, $selectCountOnly) { |
| 988 |
global $abj404_redirect_types; |
| 989 |
global $abj404_captured_types; |
| 990 |
global $wpdb; |
| 991 |
|
| 992 |
$logsTableColumns = ''; |
| 993 |
$logsTableColumns = "null as logshits, \n null as logsid, \n null as last_used, \n"; |
| 994 |
$logsTableJoin = ''; |
| 995 |
$statusTypes = ''; |
| 996 |
$trashValue = ''; |
| 997 |
$selectCountReplacement = '/* selecting data as usual */'; |
| 998 |
|
| 999 |
/* if we only want the count(*) then comment out everything else. */ |
| 1000 |
if ($selectCountOnly) { |
| 1001 |
$selectCountReplacement = "\n /*+ SET_VAR(max_join_size=18446744073709551615) */\n" . |
| 1002 |
"count(*) as count\n /* only selecting for count"; |
| 1003 |
} |
| 1004 |
|
| 1005 |
if ($queryAllRowsAtOnce && !$selectCountOnly) { |
| 1006 |
// create a temp table and use that instead of a subselect to avoid the sql error |
| 1007 |
// "The SELECT would examine more than MAX_JOIN_SIZE rows" |
| 1008 |
$this->maybeUpdateRedirectsForViewHitsTable(); |
| 1009 |
|
| 1010 |
// Verify table was actually created before using it (handles silent creation failures) |
| 1011 |
if ($this->logsHitsTableExists()) { |
| 1012 |
// if we're showing all rows include all of the log data in the query already. this makes the query very slow. |
| 1013 |
// this should be replaced by the dynamic loading of log data using ajax queries as the page is viewed. |
| 1014 |
$logsTableColumns = "logstable.logshits as logshits, \n" . |
| 1015 |
"logstable.logsid, \n" . |
| 1016 |
"logstable.last_used, \n"; |
| 1017 |
|
| 1018 |
// canonical_url is the persisted CONCAT('/', TRIM(BOTH '/' FROM url)) |
| 1019 |
// form (added 4.1.10) so this JOIN is a single indexed equality |
| 1020 |
// lookup against logs_hits.requested_url instead of evaluating |
| 1021 |
// the function on every redirects row. The COALESCE fallback |
| 1022 |
// covers rows from upgraded sites where the chunked backfill |
| 1023 |
// hasn't reached yet — those rows merge in via the original |
| 1024 |
// expression so behavior matches pre-upgrade exactly. |
| 1025 |
// wp_abj404_redirects below is the SQL alias from the assembled FROM clause, not a hardcoded table name. |
| 1026 |
$logsTableJoin = " LEFT OUTER JOIN {wp_abj404_logs_hits} logstable \n " . |
| 1027 |
" on binary logstable.requested_url = " . |
| 1028 |
"binary COALESCE(wp_abj404_redirects.canonical_url, " . // allow-prefix-literal: SQL alias |
| 1029 |
"concat('/', trim(both '/' from wp_abj404_redirects.url))) \n "; // allow-prefix-literal: SQL alias |
| 1030 |
} else { |
| 1031 |
// Fall back to null columns if table creation failed |
| 1032 |
$this->logger->debugMessage("logs_hits table not available, falling back to null columns"); |
| 1033 |
} |
| 1034 |
} |
| 1035 |
|
| 1036 |
if ($tableOptions['filter'] == 0 || $tableOptions['filter'] == ABJ404_TRASH_FILTER) { |
| 1037 |
if ($sub == 'abj404_redirects') { |
| 1038 |
$statusTypes = implode(", ", $abj404_redirect_types); |
| 1039 |
|
| 1040 |
} else if ($sub == 'abj404_captured') { |
| 1041 |
$statusTypes = implode(", ", $abj404_captured_types); |
| 1042 |
|
| 1043 |
} else { |
| 1044 |
$this->logger->errorMessage("Unrecognized sub type: " . esc_html($sub)); |
| 1045 |
} |
| 1046 |
|
| 1047 |
} else if ($tableOptions['filter'] == ABJ404_STATUS_MANUAL) { |
| 1048 |
$statusTypes = implode(", ", array(ABJ404_STATUS_MANUAL, ABJ404_STATUS_REGEX)); |
| 1049 |
|
| 1050 |
} else if ($tableOptions['filter'] == ABJ404_HANDLED_FILTER) { |
| 1051 |
// Composite filter: Ignored + Later (Simple mode "Handled" tab) |
| 1052 |
$statusTypes = implode(", ", array(ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER)); |
| 1053 |
|
| 1054 |
} else { |
| 1055 |
$statusTypes = $tableOptions['filter']; |
| 1056 |
} |
| 1057 |
$statusTypes = preg_replace('/[^\d, ]/', '', trim(is_string($statusTypes) ? $statusTypes : '')); |
| 1058 |
|
| 1059 |
if ($tableOptions['filter'] == ABJ404_TRASH_FILTER) { |
| 1060 |
$trashValue = 1; |
| 1061 |
} else if ($tableOptions['filter'] == ABJ404_HANDLED_FILTER) { |
| 1062 |
// Show both active (disabled=0) and trashed (disabled=1) in Handled view |
| 1063 |
$trashValue = 0; |
| 1064 |
} else { |
| 1065 |
$trashValue = 0; |
| 1066 |
} |
| 1067 |
|
| 1068 |
/* only try to order by if we're actually selecting data and not only |
| 1069 |
* counting the number of rows. */ |
| 1070 |
$orderByString = ''; |
| 1071 |
if (!$selectCountOnly) { |
| 1072 |
$rawOrderBy = $tableOptions['orderby'] ?? ''; |
| 1073 |
$orderBy = $this->f->strtolower(is_string($rawOrderBy) ? $rawOrderBy : ''); |
| 1074 |
if ($orderBy == "final_dest") { |
| 1075 |
// TODO change the final dest type to an integer and store external URLs somewhere else. |
| 1076 |
$orderBy = "case when post_title is null then 1 else 0 end asc, post_title"; |
| 1077 |
} else { |
| 1078 |
// only allow letters and the underscore in the orderby string. |
| 1079 |
$orderBy = preg_replace('/[^a-zA-Z_]/', '', trim($orderBy)); |
| 1080 |
} |
| 1081 |
$rawOrderVal = $tableOptions['order'] ?? ''; |
| 1082 |
$rawOrderValX = is_string($rawOrderVal) ? $rawOrderVal : ''; |
| 1083 |
$order = strtoupper((string)preg_replace('/[^a-zA-Z_]/', '', trim($rawOrderValX))); |
| 1084 |
if ($order !== 'DESC') { |
| 1085 |
$order = 'ASC'; |
| 1086 |
} |
| 1087 |
$orderByString = "order by published_status asc, " . $orderBy . " " . $order . |
| 1088 |
", wp_abj404_redirects.url ASC, wp_abj404_redirects.id " . $order; // allow-prefix-literal: SQL alias bound by `FROM {wp_abj404_redirects} wp_abj404_redirects` |
| 1089 |
} |
| 1090 |
|
| 1091 |
// Score range filter clause. wp_abj404_redirects below is the SQL alias from the assembled FROM clause, not a hardcoded table name. |
| 1092 |
$rawScoreRange = is_string($tableOptions['score_range'] ?? '') ? ($tableOptions['score_range'] ?? 'all') : 'all'; |
| 1093 |
switch ($rawScoreRange) { |
| 1094 |
case 'high': |
| 1095 |
$scoreRangeClause = 'AND wp_abj404_redirects.score >= 80'; // allow-prefix-literal: SQL alias |
| 1096 |
break; |
| 1097 |
case 'medium': |
| 1098 |
$scoreRangeClause = 'AND wp_abj404_redirects.score >= 50 AND wp_abj404_redirects.score < 80'; // allow-prefix-literal: SQL alias |
| 1099 |
break; |
| 1100 |
case 'low': |
| 1101 |
$scoreRangeClause = 'AND wp_abj404_redirects.score IS NOT NULL AND wp_abj404_redirects.score < 50'; // allow-prefix-literal: SQL alias |
| 1102 |
break; |
| 1103 |
case 'manual': |
| 1104 |
$scoreRangeClause = 'AND wp_abj404_redirects.score IS NULL'; // allow-prefix-literal: SQL alias |
| 1105 |
break; |
| 1106 |
default: |
| 1107 |
$scoreRangeClause = ''; |
| 1108 |
break; |
| 1109 |
} |
| 1110 |
|
| 1111 |
$searchFilterForRedirectsExists = "no redirects fiter text found"; |
| 1112 |
$searchFilterForCapturedExists = "no captured 404s filter text found"; |
| 1113 |
$filterText = ''; |
| 1114 |
$rawFilterText = is_string($tableOptions['filterText'] ?? null) ? $tableOptions['filterText'] : ''; |
| 1115 |
if ($rawFilterText != '') { |
| 1116 |
if ($sub == 'abj404_redirects') { |
| 1117 |
// Close the comment without including user input to avoid comment breakout. |
| 1118 |
$searchFilterForRedirectsExists = ' filter text enabled */'; |
| 1119 |
|
| 1120 |
} else if ($sub == 'abj404_captured') { |
| 1121 |
// Close the comment without including user input to avoid comment breakout. |
| 1122 |
$searchFilterForCapturedExists = ' filter text enabled */'; |
| 1123 |
|
| 1124 |
} else { |
| 1125 |
throw new Exception("Unrecognized page for filter text request."); |
| 1126 |
} |
| 1127 |
} |
| 1128 |
|
| 1129 |
// Sanitize filter text for use inside LIKE; strip comment markers and escape for SQL LIKE. |
| 1130 |
$filterTextRaw = str_replace(array('*', '/', '$'), '', $rawFilterText); |
| 1131 |
if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'esc_like')) { |
| 1132 |
/** @var wpdb $wpdb */ |
| 1133 |
$filterTextRaw = $wpdb->esc_like($filterTextRaw); |
| 1134 |
} else { |
| 1135 |
$filterTextRaw = addcslashes($filterTextRaw, '_%\\'); |
| 1136 |
} |
| 1137 |
$filterText = esc_sql($filterTextRaw); |
| 1138 |
|
| 1139 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getRedirectsForView.sql"); |
| 1140 |
// Ensure consistent collation for string operations (e.g., REPLACE/LOWER) to avoid |
| 1141 |
// "Illegal mix of collations" errors when plugin tables use *_bin collations. |
| 1142 |
$wpdbCollate = 'utf8mb4_unicode_ci'; |
| 1143 |
$hasForcedCollate = false; |
| 1144 |
if (array_key_exists('forceCollate', $tableOptions) && !empty($tableOptions['forceCollate'])) { |
| 1145 |
$rawForceCollateVal = $tableOptions['forceCollate']; |
| 1146 |
$rawForceCollate = is_string($rawForceCollateVal) ? $rawForceCollateVal : ''; |
| 1147 |
$forced = preg_replace('/[^A-Za-z0-9_]/', '', $rawForceCollate); |
| 1148 |
if ($forced !== '') { |
| 1149 |
$wpdbCollate = $forced; |
| 1150 |
$hasForcedCollate = true; |
| 1151 |
} |
| 1152 |
} |
| 1153 |
if (!$hasForcedCollate && isset($wpdb) && isset($wpdb->collate) && !empty($wpdb->collate)) { |
| 1154 |
$wpdbCollate = preg_replace('/[^A-Za-z0-9_]/', '', $wpdb->collate); |
| 1155 |
} |
| 1156 |
if ($wpdbCollate === '') { |
| 1157 |
$wpdbCollate = 'utf8mb4_unicode_ci'; |
| 1158 |
} |
| 1159 |
$query = $this->f->str_replace('{selecting-for-count-true-false}', $selectCountReplacement, $query); |
| 1160 |
$query = $this->f->str_replace('{statusTypes}', $statusTypes, $query); |
| 1161 |
$query = $this->f->str_replace('{orderByString}', $orderByString, $query); |
| 1162 |
$query = $this->f->str_replace('{limitStart}', (string)$limitStart, $query); |
| 1163 |
$query = $this->f->str_replace('{limitEnd}', (string)$limitEnd, $query); |
| 1164 |
$query = $this->f->str_replace('{searchFilterForRedirectsExists}', $searchFilterForRedirectsExists, $query); |
| 1165 |
$query = $this->f->str_replace('{searchFilterForCapturedExists}', $searchFilterForCapturedExists, $query); |
| 1166 |
$query = $this->f->str_replace('{filterText}', $filterText, $query); |
| 1167 |
$query = $this->f->str_replace('{wpdb_collate}', $wpdbCollate, $query); |
| 1168 |
$query = $this->f->str_replace('{logsTableColumns}', $logsTableColumns, $query); |
| 1169 |
$query = $this->f->str_replace('{logsTableJoin}', $logsTableJoin, $query); |
| 1170 |
$query = $this->f->str_replace('{trashValue}', (string)$trashValue, $query); |
| 1171 |
$query = $this->f->str_replace('{scoreRangeClause}', $scoreRangeClause, $query); |
| 1172 |
$query = $this->doTableNameReplacements($query); |
| 1173 |
|
| 1174 |
if (array_key_exists('translations', $tableOptions) && is_array($tableOptions['translations'])) { |
| 1175 |
$keys = array_keys($tableOptions['translations']); |
| 1176 |
$values = array_values($tableOptions['translations']); |
| 1177 |
/** @var array<int, string> $keys */ |
| 1178 |
$query = $this->f->str_replace($keys, array_map('strval', $values), $query); |
| 1179 |
} |
| 1180 |
|
| 1181 |
$query = $this->f->doNormalReplacements($query); |
| 1182 |
|
| 1183 |
return $query; |
| 1184 |
} |
| 1185 |
|
| 1186 |
/** |
| 1187 |
* Build an actionable query failure message for table warmup errors. |
| 1188 |
* |
| 1189 |
* @param string $queryLabel |
| 1190 |
* @param string $query |
| 1191 |
* @param array<string, mixed> $result |
| 1192 |
* @return string |
| 1193 |
*/ |
| 1194 |
private function formatViewQueryFailureMessage(string $queryLabel, string $query, array $result): string { |
| 1195 |
$lastErrorRaw = $result['last_error'] ?? ''; |
| 1196 |
$lastError = is_string($lastErrorRaw) ? trim($lastErrorRaw) : ''; |
| 1197 |
$timedOut = !empty($result['timed_out']); |
| 1198 |
$sqlSource = $this->extractSqlFilename($query); |
| 1199 |
|
| 1200 |
if ($lastError === '' && $timedOut) { |
| 1201 |
$lastError = $queryLabel . ' timed out'; |
| 1202 |
} else if ($lastError === '') { |
| 1203 |
$lastError = $queryLabel . ' failed without a database error message'; |
| 1204 |
} |
| 1205 |
|
| 1206 |
return $queryLabel . ' failed' |
| 1207 |
. '; last_error=' . $lastError |
| 1208 |
. '; timed_out=' . ($timedOut ? 'true' : 'false') |
| 1209 |
. '; sql_source=' . $sqlSource; |
| 1210 |
} |
| 1211 |
|
| 1212 |
/** |
| 1213 |
* @param array<int, string> $postIDs |
| 1214 |
* @return array<int, mixed> |
| 1215 |
*/ |
| 1216 |
function getExtraDataToPermalinkSuggestions(array $postIDs): array { |
| 1217 |
// Sanitize all post IDs to prevent SQL injection |
| 1218 |
$postIDs = array_map('absint', $postIDs); |
| 1219 |
$postIDJoined = implode(", ", $postIDs); |
| 1220 |
|
| 1221 |
$query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getAdditionalPostData.sql"); |
| 1222 |
$query = $this->f->str_replace('{IDS_TO_INCLUDE}', $postIDJoined, $query); |
| 1223 |
$query = $this->doTableNameReplacements($query); |
| 1224 |
$query = $this->f->doNormalReplacements($query); |
| 1225 |
|
| 1226 |
$results = $this->queryAndGetResults($query); |
| 1227 |
|
| 1228 |
/** @var array<int, mixed> $rows */ |
| 1229 |
$rows = is_array($results['rows']) ? $results['rows'] : array(); |
| 1230 |
return $rows; |
| 1231 |
} |
| 1232 |
|
| 1233 |
/** |
| 1234 |
* Prepare a WordPress SQL query with placeholders and an associative data array. |
| 1235 |
* |
| 1236 |
* @param string $query The SQL query string with {placeholder} style placeholders. |
| 1237 |
* @param array<string, mixed> $data An associative array with keys matching the placeholders in the query. |
| 1238 |
* @return string The fully prepared SQL query. |
| 1239 |
*/ |
| 1240 |
function prepare_query_wp($query, $data) { |
| 1241 |
global $wpdb; |
| 1242 |
list($prepared_query, $ordered_values) = $this->prepare_query($query, $data); |
| 1243 |
// DAO-bypass-approved: $wpdb->prepare is read-only string formatting; callers execute the result through queryAndGetResults |
| 1244 |
return $wpdb->prepare($prepared_query, $ordered_values); |
| 1245 |
} |
| 1246 |
|
| 1247 |
/** |
| 1248 |
* Prepare a SQL query with placeholders and an associative data array. |
| 1249 |
* |
| 1250 |
* @param string $query The SQL query string with {placeholder} style placeholders. |
| 1251 |
* @param array<string, mixed> $data An associative array with keys matching the placeholders in the query. |
| 1252 |
* @return array{0: string, 1: array<int, mixed>} Returns an array containing two elements: the prepared query string with %s or %d placeholders, and an ordered array of values for those placeholders. |
| 1253 |
*/ |
| 1254 |
function prepare_query($query, $data) { |
| 1255 |
$ordered_values = []; |
| 1256 |
$prepared_query = preg_replace_callback('/\{(\w+)\}/', function($matches) use ($data, &$ordered_values) { |
| 1257 |
$key = $matches[1]; |
| 1258 |
if (!isset($data[$key])) { |
| 1259 |
// Placeholder key not found in data array, ignore and continue |
| 1260 |
return $matches[0]; |
| 1261 |
} |
| 1262 |
$value = $data[$key]; |
| 1263 |
|
| 1264 |
// Append the value to the ordered values array |
| 1265 |
$ordered_values[] = $value; |
| 1266 |
|
| 1267 |
// Determine the placeholder type |
| 1268 |
$placeholder_type = is_int($value) ? '%d' : '%s'; |
| 1269 |
|
| 1270 |
return $placeholder_type; |
| 1271 |
}, $query); |
| 1272 |
|
| 1273 |
return [$prepared_query !== null ? $prepared_query : $query, $ordered_values]; |
| 1274 |
} |
| 1275 |
|
| 1276 |
/** |
| 1277 |
* Check if the hits table needs to be rebuilt. |
| 1278 |
* |
| 1279 |
* Rebuild is needed if: |
| 1280 |
* 1. MAX(id) from logs differs from stored value (new entries or deletions) |
| 1281 |
* 2. Table is older than HITS_TABLE_MAX_AGE_SECONDS (staleness check) |
| 1282 |
* |
| 1283 |
* @return bool True if rebuild needed |
| 1284 |
*/ |
| 1285 |
} |
| 1286 |
|