PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / DataAccessTrait_ViewMetadata.php

DataAccessTrait_ViewMetadata.php in 404 Solution 4.1.19, at includes/DataAccessTrait_ViewMetadata.php

589 lines 23.2 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 trait ABJ_404_Solution_DataAccess_ViewMetadataTrait {
8
9 /** @return array<string, mixed> */
10 function getTableEngines() {
11 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/selectTableEngines.sql");
12 $results = $this->queryAndGetResults($query);
13 return $results;
14 }
15
16 /** @return bool */
17 function isMyISAMSupported(): bool {
18 $abj404dao = abj_service('data_access');
19 $supportResults = $abj404dao->queryAndGetResults("SELECT ENGINE, SUPPORT " .
20 "FROM information_schema.ENGINES WHERE lower(ENGINE) = 'myisam'",
21 array('log_errors' => false));
22
23 if (!empty($supportResults) && !empty($supportResults['rows']) && is_array($supportResults['rows'])) {
24 $rows = $supportResults['rows'];
25 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
26 $supportValue = array_key_exists('support', $row) ? (string)($row['support'] ?? '') :
27 (array_key_exists('SUPPORT', $row) ? (string)($row['SUPPORT'] ?? '') : "nope");
28
29 return strtolower($supportValue) == 'yes';
30 }
31 return false;
32 }
33
34 /** Insert data into the database.
35 * Create my own insert statement because wordpress messes it up when the field
36 * length is too long. this also returns the correct value for the last_query.
37 * @global type $wpdb
38 * @param string $tableName
39 * @param array<string, mixed> $dataToInsert
40 * @return array<string, mixed>
41 */
42 function insertAndGetResults($tableName, $dataToInsert) {
43 $tableName = $this->doTableNameReplacements($tableName);
44
45 $columns = array();
46 $placeholders = array();
47 $values = array();
48
49 foreach ($dataToInsert as $column => $value) {
50 $columns[] = '`' . $column . '`';
51
52 if ($value === null) {
53 $placeholders[] = 'NULL';
54 } else {
55 $currentDataType = gettype($value);
56 if ($currentDataType == 'integer' || $currentDataType == 'double') {
57 $placeholders[] = '%d';
58 $values[] = $value;
59 } elseif ($currentDataType == 'boolean') {
60 $placeholders[] = '%d';
61 $values[] = $value ? 1 : 0;
62 } else {
63 $placeholders[] = '%s';
64 $values[] = is_scalar($value) ? (string)$value : '';
65 }
66 }
67 }
68
69 $sql = 'INSERT INTO `' . $tableName . '` (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $placeholders) . ')';
70
71 return $this->queryAndGetResults($sql, ['query_params' => $values]);
72 }
73
74 /**
75 * @return int the total number of redirects that have been captured.
76 */
77 function getCapturedCount() {
78 $query = "select count(id) from {wp_abj404_redirects} where status = " . absint(ABJ404_STATUS_CAPTURED);
79
80 $result = $this->queryAndGetResults($query);
81 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
82 return 0;
83 }
84
85 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
86 if (empty($rows)) {
87 return 0;
88 }
89 $first = $rows[0];
90 $value = is_array($first) ? reset($first) : $first;
91 return intval($value);
92 }
93
94 /** Get all of the post types from the wp_posts table.
95 * @return array<int, string> An array of post type names. */
96 function getAllPostTypes() {
97 $query = "SELECT DISTINCT post_type FROM {wp_posts} order by post_type";
98 $results = $this->queryAndGetResults($query);
99 $rows = $results['rows'];
100
101 $postType = array();
102
103 if (is_array($rows)) {
104 foreach ($rows as $row) {
105 array_push($postType, $row['post_type']);
106 }
107 }
108
109 return $postType;
110 }
111
112 /** Get the approximate number of bytes used by the logs table.
113 *
114 * @return int Bytes used by the logs table, 0 on missing/empty stats,
115 * or -1 if the lookup itself failed/timed out.
116 */
117 function getLogDiskUsage() {
118 $query = 'SELECT (data_length+index_length) tablesize FROM information_schema.tables '
119 . 'WHERE table_name=\'{wp_abj404_logsv2}\'';
120
121 $result = $this->queryAndGetResults($query);
122
123 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
124 $err = isset($result['last_error']) && is_string($result['last_error']) ? $result['last_error'] : '';
125 if ($err !== '') {
126 $this->logger->errorMessage("Error: " . esc_html($err));
127 }
128 return -1;
129 }
130
131 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
132 if (empty($rows)) {
133 return 0;
134 }
135
136 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
137 $size = $row['tablesize'] ?? null;
138 if ($size === null || !is_scalar($size)) {
139 return 0;
140 }
141 return intval($size);
142 }
143
144 /**
145 * @global type $wpdb
146 * @param array<int, int> $types specified types such as ABJ404_STATUS_MANUAL, ABJ404_STATUS_AUTO, ABJ404_STATUS_CAPTURED, ABJ404_STATUS_IGNORED.
147 * @param int $trashed 1 to only include disabled redirects. 0 to only include enabled redirects.
148 * @return int the number of records matching the specified types.
149 */
150 function getRecordCount($types = array(), $trashed = 0) {
151 $recordCount = 0;
152
153 if (count($types) >= 1) {
154 $query = "select count(id) as count from {wp_abj404_redirects} where 1 and (status in (";
155
156 $filteredTypes = array_map('absint', $types);
157 $typesForSQL = implode(", ", $filteredTypes);
158 $query .= $typesForSQL . "))";
159 $query .= " and disabled = " . absint($trashed);
160
161 $result = $this->queryAndGetResults($query);
162 $rows = is_array($result['rows']) ? $result['rows'] : array();
163 if (!empty($rows)) {
164 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
165 $recordCount = isset($row['count']) && is_scalar($row['count']) ? intval($row['count']) : 0;
166 }
167 }
168
169 return intval($recordCount);
170 }
171
172 /**
173 * Capture a structured diagnostics snapshot when getRedirectsForView() or
174 * getRedirectsForViewCount() fails or times out. The snapshot is intended
175 * to give support enough evidence in a single debug zip / AJAX response to
176 * identify the root cause of slow view queries (missing index, MyISAM
177 * corruption, multi-million-row logsv2, canonical_url not backfilled,
178 * collation mismatch, etc.) without a follow-up round trip to the user.
179 *
180 * Every sub-probe is wrapped in try/catch with a tight per-query timeout:
181 * one failed probe never blocks the others, and the original failure is
182 * never masked by a diagnostic capture exception.
183 *
184 * @param string $sub Subpage that triggered the query (abj404_redirects / abj404_captured / abj404_logs).
185 * @param string $failedQuery The SQL that failed (used for EXPLAIN + redacted shape).
186 * @param array<string, mixed> $tableOptions The original tableOptions (kept for context echo).
187 * @param array<string, mixed> $queryResult The wpdb-shaped result of the failed call (last_error / timed_out / elapsed_time).
188 * @return array<string, mixed>
189 */
190 public function captureViewQueryFailureDiagnostics(string $sub, string $failedQuery, array $tableOptions, array $queryResult): array {
191 $diag = array(
192 'failed_query_label' => '',
193 'failed_query_redacted' => '',
194 'last_error' => '',
195 'timed_out' => false,
196 'elapsed_time_seconds' => null,
197 'sub' => $sub,
198 'redirects_count' => array('active' => null, 'trashed' => null),
199 'logsv2_count' => null,
200 'wp_posts_count' => null,
201 'tables' => array(),
202 'expected_indexes' => array(),
203 'canonical_url_state' => array(),
204 'db_version' => '',
205 'explain' => null,
206 );
207
208 $diag['failed_query_label'] = $this->resolveViewQueryDiagnosticLabel($failedQuery, $sub);
209 $diag['failed_query_redacted'] = $this->redactQueryShapeForDiagnostics($failedQuery);
210
211 $lastError = is_string($queryResult['last_error'] ?? null) ? $queryResult['last_error'] : '';
212 $diag['last_error'] = $lastError;
213 $diag['timed_out'] = !empty($queryResult['timed_out']);
214 if (isset($queryResult['elapsed_time']) && is_numeric($queryResult['elapsed_time'])) {
215 $diag['elapsed_time_seconds'] = (float)$queryResult['elapsed_time'];
216 }
217
218 global $wpdb;
219 $redirectsTable = $this->doTableNameReplacements('{wp_abj404_redirects}');
220 $logsv2Table = $this->doTableNameReplacements('{wp_abj404_logsv2}');
221 $postsTable = $this->resolvePostsTableName();
222
223 $diag['explain'] = $this->safeProbeExplain($failedQuery);
224 $diag['db_version'] = $this->safeProbeDbVersion();
225
226 $diag['redirects_count']['active'] = $this->safeProbeCount(
227 "SELECT COUNT(*) AS count FROM `" . $redirectsTable . "` WHERE disabled = 0"
228 );
229 $diag['redirects_count']['trashed'] = $this->safeProbeCount(
230 "SELECT COUNT(*) AS count FROM `" . $redirectsTable . "` WHERE disabled = 1"
231 );
232 $diag['logsv2_count'] = $this->safeProbeCount(
233 "SELECT COUNT(*) AS count FROM `" . $logsv2Table . "`"
234 );
235 if ($postsTable !== '') {
236 $diag['wp_posts_count'] = $this->safeProbeCount(
237 "SELECT COUNT(*) AS count FROM `" . $postsTable . "`"
238 );
239 }
240
241 $diag['tables'] = $this->safeProbeTableEnginesAndCollations(array($redirectsTable, $logsv2Table));
242
243 $diag['expected_indexes'] = array(
244 $redirectsTable => $this->safeProbeIndexCoverage($redirectsTable, array(
245 'PRIMARY', 'status', 'type', 'code', 'timestamp', 'disabled', 'url', 'final_dest',
246 'idx_url_disabled_status', 'idx_status_disabled', 'idx_canonical_url',
247 )),
248 $logsv2Table => $this->safeProbeIndexCoverage($logsv2Table, array(
249 'PRIMARY', 'timestamp', 'requested_url', 'username', 'min_log_id',
250 'idx_requested_url_timestamp', 'idx_canonical_url',
251 )),
252 );
253
254 $diag['canonical_url_state'] = array(
255 $redirectsTable => $this->safeProbeCanonicalUrlState($redirectsTable),
256 $logsv2Table => $this->safeProbeCanonicalUrlState($logsv2Table),
257 );
258
259 return $diag;
260 }
261
262 /**
263 * Resolve a human-readable label for the failing query. Mirrors the
264 * sql_source extraction from formatViewQueryFailureMessage() so the
265 * diagnostic snapshot stays self-explanatory in the debug log.
266 *
267 * @param string $failedQuery
268 * @param string $sub
269 * @return string
270 */
271 private function resolveViewQueryDiagnosticLabel(string $failedQuery, string $sub): string {
272 if (preg_match('/\/\*\s*-+\s*(.+?\.sql)\s+BEGIN\s*-+\s*\*\//i', $failedQuery, $m)) {
273 return basename($m[1]);
274 }
275 if (stripos($failedQuery, 'COUNT(*)') !== false) {
276 return 'getRedirectsForViewCount';
277 }
278 return 'getRedirectsForView';
279 }
280
281 /**
282 * Redact literals from a SQL string for safe inclusion in error responses
283 * and the debug log. Keeps table / column / keyword shape so support can
284 * pattern-match against the failing query, but strips quoted strings,
285 * numbers, and IN(...) value lists.
286 *
287 * @param string $sql
288 * @return string
289 */
290 private function redactQueryShapeForDiagnostics(string $sql): string {
291 if ($sql === '') {
292 return '';
293 }
294 $out = $sql;
295 $out = preg_replace("~'(?:\\\\'|''|[^'])*'~", "?", $out) ?? $out;
296 $out = preg_replace('~"(?:\\\\"|""|[^"])*"~', "?", $out) ?? $out;
297 $out = preg_replace('~\\b0x[0-9A-Fa-f]+\\b~', '?', $out) ?? $out;
298 $out = preg_replace('~\\b\\d+(?:\\.\\d+)?\\b~', '?', $out) ?? $out;
299 $out = preg_replace('~\\(\\s*\\?\\s*(?:,\\s*\\?\\s*)+\\)~', '(?)', $out) ?? $out;
300 $out = preg_replace('~\\s+~', ' ', trim($out)) ?? $out;
301 if (strlen($out) > 4000) {
302 $out = substr($out, 0, 4000);
303 }
304 return $out;
305 }
306
307 /** @return string */
308 private function resolvePostsTableName(): string {
309 global $wpdb;
310 if (isset($wpdb->posts) && is_string($wpdb->posts) && $wpdb->posts !== '') {
311 return $wpdb->posts;
312 }
313 if (isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '') {
314 return $wpdb->prefix . 'posts';
315 }
316 return '';
317 }
318
319 /**
320 * Run a `SELECT COUNT(*)` style query with a tight diagnostic timeout
321 * and silent error handling. Returns the integer count, or a string error
322 * marker if the probe itself failed.
323 *
324 * @param string $countQuery
325 * @return int|string
326 */
327 private function safeProbeCount(string $countQuery) {
328 try {
329 $result = $this->queryAndGetResults($countQuery, array(
330 'timeout' => 5,
331 'log_errors' => false,
332 'skip_repair' => true,
333 ));
334 $lastErrorRaw = $result['last_error'] ?? '';
335 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
336 if ($err !== '' || !empty($result['timed_out'])) {
337 return 'error: ' . ($err !== '' ? $err : 'timed out');
338 }
339 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
340 if (empty($rows)) {
341 return 0;
342 }
343 $first = is_array($rows[0]) ? $rows[0] : array();
344 $value = $first['count'] ?? $first['COUNT(*)'] ?? reset($first);
345 return is_scalar($value) ? (int)$value : 0;
346 } catch (Throwable $e) {
347 return 'error: ' . $e->getMessage();
348 }
349 }
350
351 /**
352 * Run EXPLAIN against the failing query and return the plan rows. Falls
353 * back to a string error marker if EXPLAIN itself errors (e.g., the query
354 * was a SET STATEMENT wrapper or a stored procedure call).
355 *
356 * @param string $failedQuery
357 * @return array<int, array<string,mixed>>|string
358 */
359 private function safeProbeExplain(string $failedQuery) {
360 if ($failedQuery === '') {
361 return 'error: no query supplied';
362 }
363 $stripped = $this->stripWrappersForExplain($failedQuery);
364 try {
365 $result = $this->queryAndGetResults('EXPLAIN ' . $stripped, array(
366 'timeout' => 5,
367 'log_errors' => false,
368 'skip_repair' => true,
369 ));
370 $lastErrorRaw = $result['last_error'] ?? '';
371 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
372 if ($err !== '' || !empty($result['timed_out'])) {
373 return 'error: ' . ($err !== '' ? $err : 'timed out');
374 }
375 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
376 $clean = array();
377 foreach ($rows as $row) {
378 if (is_array($row)) {
379 $clean[] = $row;
380 } else if (is_object($row)) {
381 $clean[] = (array)$row;
382 }
383 }
384 return $clean;
385 } catch (Throwable $e) {
386 return 'error: ' . $e->getMessage();
387 }
388 }
389
390 /**
391 * Strip the `SET STATEMENT max_statement_time=N FOR ` / `/*+ MAX_EXECUTION_TIME(...) *\/`
392 * wrappers that applyQueryTimeout() prepends so EXPLAIN sees the original
393 * SELECT shape. Best effort: if the input does not match, return as-is.
394 *
395 * @param string $query
396 * @return string
397 */
398 private function stripWrappersForExplain(string $query): string {
399 $q = trim($query);
400 $q = preg_replace('/^\\s*\\/\\*\\+[^*]*\\*\\/\\s*/', '', $q) ?? $q;
401 $q = preg_replace('/^\\s*SET\\s+STATEMENT\\s+max_statement_time\\s*=\\s*\\d+\\s+FOR\\s+/i', '', $q) ?? $q;
402 return $q;
403 }
404
405 /** @return string */
406 private function safeProbeDbVersion(): string {
407 try {
408 $result = $this->queryAndGetResults('SELECT VERSION() AS version', array(
409 'timeout' => 5,
410 'log_errors' => false,
411 'skip_repair' => true,
412 ));
413 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
414 if (empty($rows)) {
415 return '';
416 }
417 $first = is_array($rows[0]) ? $rows[0] : array();
418 $value = $first['version'] ?? $first['VERSION()'] ?? reset($first);
419 return is_scalar($value) ? (string)$value : '';
420 } catch (Throwable $e) {
421 return 'error: ' . $e->getMessage();
422 }
423 }
424
425 /**
426 * Probe engine + collation for the requested plugin tables via
427 * information_schema.TABLES. Driver-case insensitive (MySQL drivers vary
428 * between TABLE_NAME / table_name).
429 *
430 * @param array<int, string> $tableNames
431 * @return array<string, array{engine:string, collation:string}>
432 */
433 private function safeProbeTableEnginesAndCollations(array $tableNames): array {
434 $out = array();
435 foreach ($tableNames as $name) {
436 $out[$name] = array('engine' => '', 'collation' => '');
437 }
438 if (empty($tableNames)) {
439 return $out;
440 }
441 try {
442 $list = array();
443 foreach ($tableNames as $name) {
444 $list[] = "'" . str_replace("'", "''", $name) . "'";
445 }
446 $query = "SELECT TABLE_NAME, ENGINE, TABLE_COLLATION FROM information_schema.TABLES "
447 . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (" . implode(',', $list) . ")";
448 $result = $this->queryAndGetResults($query, array(
449 'timeout' => 5,
450 'log_errors' => false,
451 'skip_repair' => true,
452 ));
453 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
454 foreach ($rows as $row) {
455 if (!is_array($row)) {
456 continue;
457 }
458 $name = '';
459 $engine = '';
460 $collation = '';
461 foreach ($row as $key => $value) {
462 $k = strtolower((string)$key);
463 if ($k === 'table_name' && is_scalar($value)) {
464 $name = (string)$value;
465 } else if ($k === 'engine' && is_scalar($value)) {
466 $engine = (string)$value;
467 } else if ($k === 'table_collation' && is_scalar($value)) {
468 $collation = (string)$value;
469 }
470 }
471 if ($name !== '' && array_key_exists($name, $out)) {
472 $out[$name] = array('engine' => $engine, 'collation' => $collation);
473 }
474 }
475 } catch (Throwable $e) {
476 foreach (array_keys($out) as $name) {
477 if ($out[$name]['engine'] === '') {
478 $out[$name] = array('engine' => 'error: ' . $e->getMessage(), 'collation' => '');
479 }
480 }
481 }
482 return $out;
483 }
484
485 /**
486 * Compare an expected index list against what SHOW INDEX reports for the
487 * named table. Returns three lists (expected, present, missing) so the
488 * support workflow can spot dropped indexes at a glance.
489 *
490 * @param string $tableName
491 * @param array<int, string> $expectedKeys
492 * @return array{expected: array<int,string>, present: array<int,string>, missing: array<int,string>, error?: string}
493 */
494 private function safeProbeIndexCoverage(string $tableName, array $expectedKeys): array {
495 $out = array(
496 'expected' => array_values($expectedKeys),
497 'present' => array(),
498 'missing' => array(),
499 );
500 try {
501 $result = $this->queryAndGetResults('SHOW INDEX FROM `' . $tableName . '`', array(
502 'timeout' => 5,
503 'log_errors' => false,
504 'skip_repair' => true,
505 ));
506 $lastErrorRaw = $result['last_error'] ?? '';
507 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
508 if ($err !== '') {
509 $out['error'] = $err;
510 $out['missing'] = $out['expected'];
511 return $out;
512 }
513 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
514 $present = array();
515 foreach ($rows as $row) {
516 if (!is_array($row)) {
517 continue;
518 }
519 foreach ($row as $key => $value) {
520 if (strtolower((string)$key) === 'key_name' && is_scalar($value)) {
521 $present[(string)$value] = true;
522 break;
523 }
524 }
525 }
526 $out['present'] = array_keys($present);
527 $missing = array();
528 foreach ($expectedKeys as $expected) {
529 if (!array_key_exists($expected, $present)) {
530 $missing[] = $expected;
531 }
532 }
533 $out['missing'] = $missing;
534 } catch (Throwable $e) {
535 $out['error'] = $e->getMessage();
536 $out['missing'] = $out['expected'];
537 }
538 return $out;
539 }
540
541 /**
542 * Probe canonical_url backfill state for one of the plugin's tables.
543 * Returns column existence, NULL count, total row count, and an error
544 * marker when the probe itself failed.
545 *
546 * @param string $tableName
547 * @return array{column_exists: bool, null_count: int|string|null, total_count: int|string|null, error?: string}
548 */
549 private function safeProbeCanonicalUrlState(string $tableName): array {
550 $out = array(
551 'column_exists' => false,
552 'null_count' => null,
553 'total_count' => null,
554 );
555 try {
556 $colResult = $this->queryAndGetResults('SHOW COLUMNS FROM `' . $tableName . '`', array(
557 'timeout' => 5,
558 'log_errors' => false,
559 'skip_repair' => true,
560 ));
561 $colRows = is_array($colResult['rows'] ?? null) ? $colResult['rows'] : array();
562 foreach ($colRows as $row) {
563 if (!is_array($row)) {
564 continue;
565 }
566 foreach ($row as $key => $value) {
567 if (strtolower((string)$key) === 'field' && is_scalar($value)
568 && strtolower((string)$value) === 'canonical_url') {
569 $out['column_exists'] = true;
570 break 2;
571 }
572 }
573 }
574 if (!$out['column_exists']) {
575 return $out;
576 }
577 $out['null_count'] = $this->safeProbeCount(
578 "SELECT COUNT(*) AS count FROM `" . $tableName . "` WHERE canonical_url IS NULL"
579 );
580 $out['total_count'] = $this->safeProbeCount(
581 "SELECT COUNT(*) AS count FROM `" . $tableName . "`"
582 );
583 } catch (Throwable $e) {
584 $out['error'] = $e->getMessage();
585 }
586 return $out;
587 }
588 }
589