PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / view-build / ViewQueryExplainDiagnostics.php

ViewQueryExplainDiagnostics.php in 404 Solution trunk, at includes/view-build/ViewQueryExplainDiagnostics.php

157 lines 5.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Best-effort EXPLAIN collector for Bruno-style admin redirects/captured view
9 * failures.
10 *
11 * This collaborator is intentionally narrow and no-throw: it only probes failed
12 * SELECTs against the redirects table from the Page Redirects / Captured 404s
13 * tabs, never mutates state, and returns a compact error/skipped string when it
14 * cannot safely collect a plan.
15 */
16 class ABJ_404_Solution_ViewQueryExplainDiagnostics {
17
18 /** @var ABJ_404_Solution_DatabaseCore */
19 private $dbCore;
20
21 /** @param ABJ_404_Solution_DatabaseCore $dbCore */
22 public function __construct(ABJ_404_Solution_DatabaseCore $dbCore) {
23 $this->dbCore = $dbCore;
24 }
25
26 /**
27 * @param string $sub
28 * @param string $failedQuery
29 * @param array<string, mixed> $queryResult
30 * @return array<int, array<string,mixed>>|string
31 */
32 public function collect(string $sub, string $failedQuery, array $queryResult) {
33 try {
34 $reason = $this->skipReason($sub, $failedQuery, $queryResult);
35 if ($reason !== '') {
36 return 'skipped: ' . $reason;
37 }
38
39 $result = $this->dbCore->queryAndGetResults('EXPLAIN ' . $this->stripWrappersForExplain($failedQuery), array(
40 'timeout' => 5,
41 'log_errors' => false,
42 'skip_repair' => true,
43 ));
44 $lastErrorRaw = $result['last_error'] ?? '';
45 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
46 if ($err !== '' || !empty($result['timed_out'])) {
47 return 'error: ' . ($err !== '' ? $err : 'timed out');
48 }
49 return $this->compactExplainRows(is_array($result['rows'] ?? null) ? $result['rows'] : array());
50 } catch (Throwable $e) {
51 return 'error: ' . $e->getMessage();
52 }
53 }
54
55 /**
56 * @param string $sub
57 * @param string $failedQuery
58 * @param array<string, mixed> $queryResult
59 * @return string Empty when the EXPLAIN probe is allowed.
60 */
61 private function skipReason(string $sub, string $failedQuery, array $queryResult): string {
62 $normalizedSub = strtolower($sub);
63 if ($normalizedSub !== 'abj404_redirects' && $normalizedSub !== 'abj404_captured') {
64 return 'not a redirects/captured admin view';
65 }
66 if ($failedQuery === '') {
67 return 'no query supplied';
68 }
69 $lastError = is_string($queryResult['last_error'] ?? null) ? trim($queryResult['last_error']) : '';
70 if ($lastError === '' && empty($queryResult['timed_out'])) {
71 return 'query did not fail or time out';
72 }
73
74 $stripped = $this->stripWrappersForExplain($failedQuery);
75 if (!preg_match('/^\\s*select\\b/i', $stripped)) {
76 return 'not a SELECT query';
77 }
78 if (stripos($stripped, 'abj404_redirects') === false) {
79 return 'not a redirects-table query';
80 }
81 return '';
82 }
83
84 /**
85 * @param string $query
86 * @return string
87 */
88 private function stripWrappersForExplain(string $query): string {
89 $q = trim($query);
90 $q = preg_replace('/^\\s*\\/\\*\\+[^*]*\\*\\/\\s*/', '', $q) ?? $q;
91 $q = preg_replace('/^\\s*SET\\s+STATEMENT\\s+max_statement_time\\s*=\\s*\\d+\\s+FOR\\s+/i', '', $q) ?? $q;
92 return $q;
93 }
94
95 /**
96 * Keep only the planner fields useful for support and cap row/string sizes
97 * so diagnostics cannot balloon a support payload.
98 *
99 * @param array<int, mixed> $rows
100 * @return array<int, array<string,mixed>>
101 */
102 private function compactExplainRows(array $rows): array {
103 $allowed = array(
104 'id' => true,
105 'select_type' => true,
106 'table' => true,
107 'type' => true,
108 'possible_keys' => true,
109 'key' => true,
110 'key_len' => true,
111 'ref' => true,
112 'rows' => true,
113 'filtered' => true,
114 'extra' => true,
115 );
116 $clean = array();
117 foreach ($rows as $row) {
118 if (count($clean) >= 10) {
119 break;
120 }
121 if (is_object($row)) {
122 $row = (array)$row;
123 }
124 if (!is_array($row)) {
125 continue;
126 }
127 $out = array();
128 foreach ($row as $key => $value) {
129 $lower = strtolower((string)$key);
130 if (!isset($allowed[$lower])) {
131 continue;
132 }
133 $out[$key] = $this->boundedScalar($value);
134 }
135 if (!empty($out)) {
136 $clean[] = $out;
137 }
138 }
139 return $clean;
140 }
141
142 /**
143 * @param mixed $value
144 * @return mixed
145 */
146 private function boundedScalar($value) {
147 if ($value === null || is_bool($value) || is_int($value) || is_float($value)) {
148 return $value;
149 }
150 if (!is_scalar($value)) {
151 return '';
152 }
153 $text = (string)$value;
154 return strlen($text) > 500 ? substr($text, 0, 500) : $text;
155 }
156 }
157