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 / database / DatabaseQueryExecutor.php

DatabaseQueryExecutor.php in 404 Solution trunk, at includes/database/DatabaseQueryExecutor.php

450 lines 18.3 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 require_once __DIR__ . '/DatabaseInfrastructureErrorTaxonomy.php';
8 require_once __DIR__ . '/../core/DatabaseMetadataLockWaitGuard.php';
9
10 /**
11 * Central query pipeline for the plugin's DAO layer.
12 *
13 * Extracted from DatabaseCore as part of the (5/6) DatabaseCore decomposition.
14 * Owns the run-a-SQL-query path that every DAO module routes through:
15 *
16 * - queryAndGetResults(): the main pipeline (option normalization, table-name
17 * substitution, prepare(), timeout wrapping, latency
18 * simulation, get_results/query call, error harvest,
19 * recovery-policy dispatch, and final error reporter
20 * dispatch).
21 * - queryScalarInt(): thin wrapper that runs a query and returns the
22 * first scalar column of the first row as an int.
23 *
24 * The executor holds a DatabaseCore back-reference and calls back into core's
25 * already-extracted helpers (connection manager, query-timeout manager, error
26 * classifier, sql-error reporter, recovery policy, table-name resolver,
27 * notice-state holder, collation helper, table repairer) by their public method
28 * names. This mirrors
29 * the back-reference pattern used by DatabaseConnectionManager,
30 * DatabaseQueryTimeoutManager, DatabaseErrorClassifier, and
31 * DatabaseSqlErrorReporter.
32 *
33 * The per-request $currentResultType state lives here (not on DatabaseCore) so
34 * the executor fully owns its pipeline state. DatabaseCore::getCurrentResultType()
35 * delegates here for back-compat with DatabaseErrorClassifier's missing-table
36 * repair path, which needs to re-run the original query with the same wpdb
37 * output type.
38 */
39 class ABJ_404_Solution_DatabaseQueryExecutor {
40
41 /** @var ABJ_404_Solution_DatabaseCore */
42 private $core;
43
44 /** @var ABJ_404_Solution_Logging */
45 private $logger;
46
47 /** @var ABJ_404_Solution_DatabaseWpdbResultHarvester */
48 private $resultHarvester;
49
50 /** @var ABJ_404_Solution_DatabaseQueryDiagnostics */
51 private $queryDiagnostics;
52
53 /** @var ABJ_404_Solution_DatabaseQueryRecoveryPolicy */
54 private $queryRecoveryPolicy;
55
56 /** @var ABJ_404_Solution_DatabaseMetadataLockWaitGuard */
57 private $metadataLockWaitGuard;
58
59 /** @var string Current wpdb result type for queryAndGetResults (ARRAY_A or OBJECT). */
60 private $currentResultType = ARRAY_A;
61
62 /**
63 * @param ABJ_404_Solution_DatabaseCore $core
64 * @param ABJ_404_Solution_Logging $logger
65 * @param ABJ_404_Solution_DatabaseWpdbResultHarvester $resultHarvester
66 * @param ABJ_404_Solution_DatabaseQueryDiagnostics $queryDiagnostics
67 * @param ABJ_404_Solution_DatabaseQueryRecoveryPolicy $queryRecoveryPolicy
68 */
69 public function __construct(
70 ABJ_404_Solution_DatabaseCore $core,
71 $logger,
72 ABJ_404_Solution_DatabaseWpdbResultHarvester $resultHarvester,
73 ABJ_404_Solution_DatabaseQueryDiagnostics $queryDiagnostics,
74 ABJ_404_Solution_DatabaseQueryRecoveryPolicy $queryRecoveryPolicy
75 ) {
76 $this->core = $core;
77 $this->logger = $logger;
78 $this->resultHarvester = $resultHarvester;
79 $this->queryDiagnostics = $queryDiagnostics;
80 $this->queryRecoveryPolicy = $queryRecoveryPolicy;
81 $this->metadataLockWaitGuard = new ABJ_404_Solution_DatabaseMetadataLockWaitGuard($logger);
82 $this->currentResultType = ARRAY_A;
83 }
84
85 /** @return string */
86 public function getCurrentResultType(): string {
87 return $this->currentResultType;
88 }
89
90 /**
91 * @param string $query
92 * @param array<string, mixed> $options
93 * @return int
94 */
95 public function queryScalarInt($query, $options = array()): int {
96 $result = $this->queryAndGetResults($query, $options);
97 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
98 if (empty($rows) || !is_array($rows[0])) {
99 return 0;
100 }
101 $first = reset($rows[0]);
102 return is_scalar($first) ? (int)$first : 0;
103 }
104
105 /**
106 * @param string $query
107 * @param array<string, mixed> $options
108 * @return array<string, mixed>
109 */
110 public function queryAndGetResults($query, $options = array()): array {
111 global $wpdb;
112
113 $preflight = $this->queryDiagnostics->beginQueryPreflight(
114 is_string($query) ? $query : '',
115 $wpdb ?? null
116 );
117 try {
118 $this->core->connectionManager()->ensureConnection($preflight);
119
120 $options = $this->normalizeQueryOptions($options);
121 $resultType = $this->normalizeResultType($options['result_type']);
122 $this->currentResultType = $resultType;
123
124 // wpdb unavailable: degrade to an empty result rather than crashing on
125 // method_exists(null, ...) or null->method() downstream. Happens in
126 // very early-life code paths (fresh-install background workers reaching
127 // the DAO before WordPress has populated $wpdb, CLI bootstrap, unit
128 // tests that exercise the suggestion pipeline without a real wpdb).
129 //
130 // "Unavailable" is not just null: an object that is not a real wpdb --
131 // one missing prepare()/get_results()/query() -- is equally unusable and
132 // must degrade the same way instead of fataling with
133 // "Call to undefined method ...::prepare()" inside prepareQueryParameters()
134 // / executeWpdbQuery(). This mirrors the method_exists() guard already
135 // used for suppress_errors() below.
136 //
137 // The DAO result contract (last_error populated, rows as an empty array)
138 // is preserved so queryAndGetResults remains the centralized
139 // graceful-degradation seam (Defensive Coding #2/#11).
140 if (!$this->wpdbCanRunQueries($wpdb)) {
141 $preflight->complete();
142 return array(
143 'rows' => array(),
144 'rows_affected' => 0,
145 'last_error' => 'wpdb unavailable',
146 'elapsed_time' => 0.0,
147 );
148 }
149
150 $ignoreErrorStrings = $this->normalizeIgnoreErrorStrings($options['ignore_errors']);
151 $queryParameters = is_array($options['query_params']) ? $options['query_params'] : array();
152
153 $query = $preflight->trace(
154 ABJ_404_Solution_DatabaseQueryPreflightTracer::PARAMETER_PREPARATION,
155 function () use ($query, $queryParameters): string {
156 $replacedQuery = $this->core->doTableNameReplacements($query);
157 return $this->prepareQueryParameters($replacedQuery, $queryParameters);
158 },
159 array('fields' => array('parameter_count' => count($queryParameters)))
160 );
161
162 $timeoutRaw = isset($options['timeout']) && is_numeric($options['timeout'])
163 ? (int)$options['timeout']
164 : 0;
165 $timeoutSeconds = $timeoutRaw > 0 ? $timeoutRaw : 60;
166 $query = $preflight->trace(
167 ABJ_404_Solution_DatabaseQueryPreflightTracer::TIMEOUT_POLICY,
168 function () use ($query, $timeoutSeconds, $preflight): string {
169 $timedQuery = $this->core->queryTimeoutManager()->applyQueryTimeout(
170 $query,
171 $timeoutSeconds,
172 $preflight
173 );
174 $this->queryDiagnostics->recordAjaxTimeoutMode($timedQuery);
175 return $timedQuery;
176 },
177 array(
178 'fields' => array('timeout_s' => $timeoutSeconds),
179 'result_fields' => static fn(string $timedQuery): array => array(
180 'timeout_mode' => preg_match(
181 '/MAX_EXECUTION_TIME|max_statement_time/i',
182 $timedQuery
183 ) === 1 ? 'wrapped' : 'unwrapped',
184 ),
185 )
186 );
187
188 $preflight->trace(
189 ABJ_404_Solution_DatabaseQueryPreflightTracer::DIAGNOSTIC_LATENCY,
190 function (): void {
191 $this->queryDiagnostics->applyDiagnosticLatencyIfConfigured();
192 }
193 );
194 $producesRows = $preflight->trace(
195 ABJ_404_Solution_DatabaseQueryPreflightTracer::RESULT_SHAPE_DETECTION,
196 fn(): bool => $this->core->queryTimeoutManager()->queryProducesResultRows($query),
197 array(
198 'result_fields' => static fn(bool $rows): array => array(
199 'result_shape' => $rows ? 'rows' : 'mutation',
200 ),
201 )
202 );
203 $preflight->complete();
204 } catch (Throwable $e) {
205 $preflight->complete('failed', $e);
206 throw $e;
207 }
208
209 // Announced before the timer starts, and therefore before the query
210 // can block: a stalled statement leaves this record as the last thing
211 // on disk, which is what names the SQL shape that hung.
212 $queryIdentity = $this->queryDiagnostics->recordQueryTimelineStart(
213 $query,
214 $timeoutSeconds,
215 $preflight->preflightId()
216 );
217 $recoveryTracer = ABJ_404_Solution_DatabaseQueryRecoveryTracer::begin(
218 $queryIdentity
219 );
220
221 $timer = new ABJ_404_Solution_Timer();
222
223 $suppressWpdbErrors = !$options['log_errors'] && method_exists($wpdb, 'suppress_errors');
224 $previousSuppressState = false;
225 if ($suppressWpdbErrors) {
226 /** @var wpdb $wpdb */
227 $previousSuppressState = $wpdb->suppress_errors(true);
228 }
229
230 $result = array();
231 try {
232 $result = ABJ_404_Solution_DatabaseQueryFilterTracer::trace(
233 $queryIdentity,
234 fn(): array => $this->executeWpdbQuery($query, $resultType, $producesRows)
235 );
236 } catch (Throwable $e) {
237 $recoveryTracer->recordFirstDriverReturn('failed', $e);
238 $recoveryTracer->startRecovery();
239 $recoveryTracer->completeRecovery('failed', $e);
240 $result['elapsed_time'] = $timer->stop();
241 $this->queryDiagnostics->recordQueryTimelineEnd(((float)$result['elapsed_time']) * 1000.0);
242 $this->core->sqlErrorReporter()->logSqlThrowable($query, $e, $options, $producesRows);
243 if ($suppressWpdbErrors) {
244 /** @var wpdb $wpdb */
245 $wpdb->suppress_errors($previousSuppressState);
246 }
247 throw $e;
248 }
249 // Preserve the first-attempt duration for the observed-error log while
250 // keeping the timer running through every retry/recovery branch below.
251 $result['elapsed_time'] = $timer->getElapsedTime();
252 $recoveryTracer->recordFirstDriverReturn();
253 $recoveryTracer->startRecovery();
254 $lastErrorForObservedLog = is_string($result['last_error'] ?? null) ? $result['last_error'] : '';
255 $retryDecision = $this->queryRecoveryPolicy->classifyRetry($lastErrorForObservedLog);
256 if ($lastErrorForObservedLog === ''
257 || $retryDecision['strategy'] === ABJ_404_Solution_DatabaseInfrastructureErrorTaxonomy::QUERY_RETRY_NONE) {
258 $this->core->sqlErrorReporter()->logObservedSqlError($query, $result, $options, $producesRows);
259 }
260
261 if ($producesRows && !is_array($result['rows'])) {
262 $this->queryDiagnostics->logMalformedRowsIfNeeded($query, $result['rows']);
263 }
264
265 $queryForBudget = $query;
266 $producesRows = $this->queryRecoveryPolicy->recoverQueryResult(
267 $query,
268 $result,
269 $options,
270 $resultType,
271 $producesRows,
272 $timeoutSeconds,
273 $recoveryTracer
274 );
275
276 $result['elapsed_time'] = $timer->stop();
277 $elapsedMs = ((float)$result['elapsed_time']) * 1000.0;
278 $this->queryDiagnostics->recordQueryTimelineEnd($elapsedMs);
279 $this->queryDiagnostics->recordQueryBudgetIfEnabled($queryForBudget, $elapsedMs, $timeoutSeconds);
280
281 if ($suppressWpdbErrors) {
282 /** @var wpdb $wpdb */
283 $wpdb->suppress_errors($previousSuppressState);
284 }
285
286 $this->core->sqlErrorReporter()->handleFinalSqlErrorReporting(
287 $query,
288 $result,
289 $options,
290 $ignoreErrorStrings,
291 $timer,
292 $recoveryTracer
293 );
294 $recoveryTracer->completeRecovery();
295
296 return $result;
297 }
298
299 /**
300 * @param array<string, mixed> $options
301 * @return array<string, mixed>
302 */
303 private function normalizeQueryOptions(array $options): array {
304 return array_merge(array(
305 'log_errors' => true,
306 'log_too_slow' => true,
307 'ignore_errors' => array(),
308 'query_params' => array(),
309 'skip_repair' => false,
310 'result_type' => ARRAY_A,
311 'timeout' => 0,
312 ), $options);
313 }
314
315 /**
316 * @param mixed $resultType
317 * @return 'OBJECT'|'ARRAY_A'
318 */
319 private function normalizeResultType($resultType): string {
320 return $resultType === OBJECT ? OBJECT : ARRAY_A;
321 }
322
323 /**
324 * @param mixed $ignoreErrors
325 * @return array<int|string, string>
326 */
327 private function normalizeIgnoreErrorStrings($ignoreErrors): array {
328 if (!is_array($ignoreErrors)) {
329 return array();
330 }
331
332 $ignoreErrorStrings = array();
333 foreach ($ignoreErrors as $key => $value) {
334 if (is_string($value)) {
335 $ignoreErrorStrings[$key] = $value;
336 }
337 }
338 return $ignoreErrorStrings;
339 }
340
341 /**
342 * Whether $wpdb is a usable query object for this executor's needs.
343 *
344 * A usable wpdb must be able to bind parameters (prepare()) and run at
345 * least one kind of statement (get_results() for SELECTs, query() for
346 * everything else). A non-object (null during early boot / CLI) -- or an
347 * object that is not a real wpdb and cannot answer those calls -- must
348 * degrade to the empty-result contract rather than fatal with
349 * "Call to undefined method ...". A real wpdb (and every db drop-in:
350 * HyperDB, LudicrousDB, ...) implements all of these, so this is a no-op on
351 * a live site and only changes behavior for a malformed $wpdb.
352 *
353 * We require prepare() plus *either* read method rather than all three so a
354 * legitimate read-only double (a wpdb that only ever runs SELECTs through
355 * this executor) is not rejected, while a bare foreign object with none of
356 * them still is.
357 *
358 * Uses is_callable() rather than method_exists() so it also accepts test
359 * doubles that route methods through __call() (e.g. Mockery wpdb mocks),
360 * while still rejecting a bare object that has neither the methods nor a
361 * __call() handler.
362 *
363 * @param mixed $wpdb
364 * @return bool
365 */
366 private function wpdbCanRunQueries($wpdb): bool {
367 return is_object($wpdb)
368 && is_callable(array($wpdb, 'prepare'))
369 && (is_callable(array($wpdb, 'get_results')) || is_callable(array($wpdb, 'query')));
370 }
371
372 /**
373 * @param string $query
374 * @param array<int|string, mixed> $queryParameters
375 * @return string
376 */
377 private function prepareQueryParameters(string $query, array $queryParameters): string {
378 if (empty($queryParameters)) {
379 return $query;
380 }
381
382 global $wpdb;
383 /** @var literal-string $queryLiteral */
384 $queryLiteral = $query;
385 $orderedParameters = array_values($queryParameters);
386 try {
387 /** @var wpdb $wpdb */
388 $preparedResult = call_user_func_array(array($wpdb, 'prepare'), array_merge(array($queryLiteral), $orderedParameters));
389 return is_string($preparedResult) ? $preparedResult : $queryLiteral;
390 } catch (Throwable $t) {
391 $this->logger->debugMessage('wpdb prepare variadic call failed; retrying with array parameters.', $t);
392 $preparedFallback = $wpdb->prepare($queryLiteral, $orderedParameters);
393 return $preparedFallback !== null ? $preparedFallback : $queryLiteral;
394 }
395 }
396
397 /**
398 * @param string $query
399 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType
400 * @param bool $producesRows
401 * @return array<string, mixed>
402 */
403 private function executeWpdbQuery(string $query, string $resultType, bool $producesRows): array {
404 global $wpdb;
405 if ($this->queryCanWaitForMetadataLock($query)) {
406 $guarded = $this->metadataLockWaitGuard->runWithBoundedWait($wpdb, array(
407 'description' => 'executing a schema or metadata statement through the database query pipeline',
408 'operation' => function () use ($wpdb, $query, $resultType, $producesRows) {
409 if ($producesRows) {
410 return $wpdb->get_results($query, $resultType);
411 }
412 $wpdb->query($query);
413 return array();
414 },
415 ));
416 $result = array('rows' => $producesRows ? $guarded['value'] : array());
417 } else if ($producesRows) {
418 $result = array('rows' => $wpdb->get_results($query, $resultType));
419 } else {
420 $wpdb->query($query);
421 $result = array('rows' => array());
422 }
423 // Snapshot wpdb synchronously before any diagnostic write, hook
424 // restoration, or logger can issue a nested query and overwrite its
425 // mutable result properties.
426 $this->resultHarvester->harvestWpdbResult($result);
427 return $result;
428 }
429
430 /**
431 * DDL and metadata inspection take metadata locks. Bound these uncommon
432 * statements without adding two session SETs to every ordinary frontend
433 * SELECT/INSERT/UPDATE.
434 */
435 private function queryCanWaitForMetadataLock(string $query): bool {
436 $unwrapped = (string)preg_replace(
437 '/^\s*SET\s+STATEMENT\s+max_statement_time\s*=\s*\d+\s+FOR\s+/i',
438 '',
439 $query,
440 1
441 );
442 $unwrapped = (string)preg_replace('/^\s*(?:\/\*[\s\S]*?\*\/\s*)+/', '', $unwrapped);
443 return preg_match(
444 '/^\s*(?:ALTER|CREATE|DROP|RENAME|TRUNCATE|SHOW|DESCRIBE|DESC|LOCK)\b/i',
445 $unwrapped
446 ) === 1;
447 }
448
449 }
450