PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
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 4.3.3, at includes/database/DatabaseQueryExecutor.php

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