| 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 |
|