| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Central query pipeline for the plugin's DAO layer. |
| 9 |
* |
| 10 |
* Extracted from DatabaseCore as part of the (5/6) DatabaseCore decomposition. |
| 11 |
* Owns the run-a-SQL-query path that every DAO module routes through: |
| 12 |
* |
| 13 |
* - queryAndGetResults(): the main pipeline (option normalization, table-name |
| 14 |
* substitution, prepare(), timeout wrapping, latency |
| 15 |
* simulation, get_results/query call, error harvest, |
| 16 |
* recovery-policy dispatch, and final error reporter |
| 17 |
* dispatch). |
| 18 |
* - queryScalarInt(): thin wrapper that runs a query and returns the |
| 19 |
* first scalar column of the first row as an int. |
| 20 |
* |
| 21 |
* The executor holds a DatabaseCore back-reference and calls back into core's |
| 22 |
* already-extracted helpers (connection manager, query-timeout manager, error |
| 23 |
* classifier, sql-error reporter, recovery policy, table-name resolver, |
| 24 |
* notice-state holder, collation helper, table repairer) by their public method |
| 25 |
* names. This mirrors |
| 26 |
* the back-reference pattern used by DatabaseConnectionManager, |
| 27 |
* DatabaseQueryTimeoutManager, DatabaseErrorClassifier, and |
| 28 |
* DatabaseSqlErrorReporter. |
| 29 |
* |
| 30 |
* The per-request $currentResultType state lives here (not on DatabaseCore) so |
| 31 |
* the executor fully owns its pipeline state. DatabaseCore::getCurrentResultType() |
| 32 |
* delegates here for back-compat with DatabaseErrorClassifier's missing-table |
| 33 |
* repair path, which needs to re-run the original query with the same wpdb |
| 34 |
* output type. |
| 35 |
*/ |
| 36 |
class ABJ_404_Solution_DatabaseQueryExecutor { |
| 37 |
|
| 38 |
/** @var ABJ_404_Solution_DatabaseCore */ |
| 39 |
private $core; |
| 40 |
|
| 41 |
/** @var ABJ_404_Solution_Logging */ |
| 42 |
private $logger; |
| 43 |
|
| 44 |
/** @var ABJ_404_Solution_DatabaseWpdbResultHarvester */ |
| 45 |
private $resultHarvester; |
| 46 |
|
| 47 |
/** @var ABJ_404_Solution_DatabaseQueryDiagnostics */ |
| 48 |
private $queryDiagnostics; |
| 49 |
|
| 50 |
/** @var ABJ_404_Solution_DatabaseQueryRecoveryPolicy */ |
| 51 |
private $queryRecoveryPolicy; |
| 52 |
|
| 53 |
/** @var string Current wpdb result type for queryAndGetResults (ARRAY_A or OBJECT). */ |
| 54 |
private $currentResultType = ARRAY_A; |
| 55 |
|
| 56 |
/** |
| 57 |
* @param ABJ_404_Solution_DatabaseCore $core |
| 58 |
* @param ABJ_404_Solution_Logging $logger |
| 59 |
* @param ABJ_404_Solution_DatabaseWpdbResultHarvester $resultHarvester |
| 60 |
* @param ABJ_404_Solution_DatabaseQueryDiagnostics $queryDiagnostics |
| 61 |
* @param ABJ_404_Solution_DatabaseQueryRecoveryPolicy $queryRecoveryPolicy |
| 62 |
*/ |
| 63 |
public function __construct( |
| 64 |
ABJ_404_Solution_DatabaseCore $core, |
| 65 |
$logger, |
| 66 |
ABJ_404_Solution_DatabaseWpdbResultHarvester $resultHarvester, |
| 67 |
ABJ_404_Solution_DatabaseQueryDiagnostics $queryDiagnostics, |
| 68 |
ABJ_404_Solution_DatabaseQueryRecoveryPolicy $queryRecoveryPolicy |
| 69 |
) { |
| 70 |
$this->core = $core; |
| 71 |
$this->logger = $logger; |
| 72 |
$this->resultHarvester = $resultHarvester; |
| 73 |
$this->queryDiagnostics = $queryDiagnostics; |
| 74 |
$this->queryRecoveryPolicy = $queryRecoveryPolicy; |
| 75 |
$this->currentResultType = ARRAY_A; |
| 76 |
} |
| 77 |
|
| 78 |
/** @return string */ |
| 79 |
public function getCurrentResultType(): string { |
| 80 |
return $this->currentResultType; |
| 81 |
} |
| 82 |
|
| 83 |
/** |
| 84 |
* @param string $query |
| 85 |
* @param array<string, mixed> $options |
| 86 |
* @return int |
| 87 |
*/ |
| 88 |
public function queryScalarInt($query, $options = array()): int { |
| 89 |
$result = $this->queryAndGetResults($query, $options); |
| 90 |
$rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array(); |
| 91 |
if (empty($rows) || !is_array($rows[0])) { |
| 92 |
return 0; |
| 93 |
} |
| 94 |
$first = reset($rows[0]); |
| 95 |
return is_scalar($first) ? (int)$first : 0; |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* @param string $query |
| 100 |
* @param array<string, mixed> $options |
| 101 |
* @return array<string, mixed> |
| 102 |
*/ |
| 103 |
public function queryAndGetResults($query, $options = array()): array { |
| 104 |
global $wpdb; |
| 105 |
|
| 106 |
$this->core->connectionManager()->ensureConnection(); |
| 107 |
|
| 108 |
$options = $this->normalizeQueryOptions($options); |
| 109 |
$resultType = $this->normalizeResultType($options['result_type']); |
| 110 |
$this->currentResultType = $resultType; |
| 111 |
|
| 112 |
// wpdb unavailable: degrade to an empty result rather than crashing on |
| 113 |
// method_exists(null, ...) or null->method() downstream. Happens in |
| 114 |
// very early-life code paths (fresh-install background workers reaching |
| 115 |
// the DAO before WordPress has populated $wpdb, CLI bootstrap, unit |
| 116 |
// tests that exercise the suggestion pipeline without a real wpdb). |
| 117 |
// |
| 118 |
// "Unavailable" is not just null: an object that is not a real wpdb -- |
| 119 |
// one missing prepare()/get_results()/query() -- is equally unusable and |
| 120 |
// must degrade the same way instead of fataling with |
| 121 |
// "Call to undefined method ...::prepare()" inside prepareQueryParameters() |
| 122 |
// / executeWpdbQuery(). This mirrors the method_exists() guard already |
| 123 |
// used for suppress_errors() below. |
| 124 |
// |
| 125 |
// The DAO result contract (last_error populated, rows as an empty array) |
| 126 |
// is preserved so queryAndGetResults remains the centralized |
| 127 |
// graceful-degradation seam (Defensive Coding #2/#11). |
| 128 |
if (!$this->wpdbCanRunQueries($wpdb)) { |
| 129 |
return array( |
| 130 |
'rows' => array(), |
| 131 |
'rows_affected' => 0, |
| 132 |
'last_error' => 'wpdb unavailable', |
| 133 |
'elapsed_time' => 0.0, |
| 134 |
); |
| 135 |
} |
| 136 |
|
| 137 |
$ignoreErrorStrings = $this->normalizeIgnoreErrorStrings($options['ignore_errors']); |
| 138 |
$queryParameters = is_array($options['query_params']) ? $options['query_params'] : array(); |
| 139 |
|
| 140 |
$query = $this->core->doTableNameReplacements($query); |
| 141 |
$query = $this->prepareQueryParameters($query, $queryParameters); |
| 142 |
|
| 143 |
$timeoutRaw = isset($options['timeout']) && is_numeric($options['timeout']) ? (int)$options['timeout'] : 0; |
| 144 |
$timeoutSeconds = $timeoutRaw > 0 ? $timeoutRaw : 60; |
| 145 |
$query = $this->core->queryTimeoutManager()->applyQueryTimeout($query, $timeoutSeconds); |
| 146 |
|
| 147 |
$this->queryDiagnostics->applyDiagnosticLatencyIfConfigured(); |
| 148 |
|
| 149 |
$timer = new ABJ_404_Solution_Timer(); |
| 150 |
|
| 151 |
$suppressWpdbErrors = !$options['log_errors'] && method_exists($wpdb, 'suppress_errors'); |
| 152 |
$previousSuppressState = false; |
| 153 |
if ($suppressWpdbErrors) { |
| 154 |
/** @var wpdb $wpdb */ |
| 155 |
$previousSuppressState = $wpdb->suppress_errors(true); |
| 156 |
} |
| 157 |
|
| 158 |
$producesRows = $this->core->queryTimeoutManager()->queryProducesResultRows($query); |
| 159 |
|
| 160 |
$result = array(); |
| 161 |
try { |
| 162 |
$result = $this->executeWpdbQuery($query, $resultType, $producesRows); |
| 163 |
} catch (Throwable $e) { |
| 164 |
$result['elapsed_time'] = $timer->stop(); |
| 165 |
$this->core->sqlErrorReporter()->logSqlThrowable($query, $e, $options, $producesRows); |
| 166 |
if ($suppressWpdbErrors) { |
| 167 |
/** @var wpdb $wpdb */ |
| 168 |
$wpdb->suppress_errors($previousSuppressState); |
| 169 |
} |
| 170 |
throw $e; |
| 171 |
} |
| 172 |
|
| 173 |
$result['elapsed_time'] = $timer->stop(); |
| 174 |
$elapsedMs = ((float)$result['elapsed_time']) * 1000.0; |
| 175 |
$this->queryDiagnostics->recordQueryBudgetIfEnabled($query, $elapsedMs, $timeoutSeconds); |
| 176 |
$this->resultHarvester->harvestWpdbResult($result); |
| 177 |
$lastErrorForObservedLog = is_string($result['last_error'] ?? null) ? $result['last_error'] : ''; |
| 178 |
if ($lastErrorForObservedLog === '' || !$this->core->errorClassifier()->taxonomy()->connectivity()->isTransientConnectionError($lastErrorForObservedLog)) { |
| 179 |
$this->core->sqlErrorReporter()->logObservedSqlError($query, $result, $options, $producesRows); |
| 180 |
} |
| 181 |
|
| 182 |
if ($producesRows && !is_array($result['rows'])) { |
| 183 |
$this->queryDiagnostics->logMalformedRowsIfNeeded($query, $result['rows']); |
| 184 |
} |
| 185 |
|
| 186 |
$producesRows = $this->queryRecoveryPolicy->recoverQueryResult( |
| 187 |
$query, $result, $options, $resultType, $producesRows, $timeoutSeconds |
| 188 |
); |
| 189 |
|
| 190 |
if ($suppressWpdbErrors) { |
| 191 |
/** @var wpdb $wpdb */ |
| 192 |
$wpdb->suppress_errors($previousSuppressState); |
| 193 |
} |
| 194 |
|
| 195 |
$this->core->sqlErrorReporter()->handleFinalSqlErrorReporting( |
| 196 |
$query, $result, $options, $ignoreErrorStrings, $timer |
| 197 |
); |
| 198 |
|
| 199 |
return $result; |
| 200 |
} |
| 201 |
|
| 202 |
/** |
| 203 |
* @param array<string, mixed> $options |
| 204 |
* @return array<string, mixed> |
| 205 |
*/ |
| 206 |
private function normalizeQueryOptions(array $options): array { |
| 207 |
return array_merge(array( |
| 208 |
'log_errors' => true, |
| 209 |
'log_too_slow' => true, |
| 210 |
'ignore_errors' => array(), |
| 211 |
'query_params' => array(), |
| 212 |
'skip_repair' => false, |
| 213 |
'result_type' => ARRAY_A, |
| 214 |
'timeout' => 0, |
| 215 |
), $options); |
| 216 |
} |
| 217 |
|
| 218 |
/** |
| 219 |
* @param mixed $resultType |
| 220 |
* @return 'OBJECT'|'ARRAY_A' |
| 221 |
*/ |
| 222 |
private function normalizeResultType($resultType): string { |
| 223 |
return $resultType === OBJECT ? OBJECT : ARRAY_A; |
| 224 |
} |
| 225 |
|
| 226 |
/** |
| 227 |
* @param mixed $ignoreErrors |
| 228 |
* @return array<int|string, string> |
| 229 |
*/ |
| 230 |
private function normalizeIgnoreErrorStrings($ignoreErrors): array { |
| 231 |
if (!is_array($ignoreErrors)) { |
| 232 |
return array(); |
| 233 |
} |
| 234 |
|
| 235 |
$ignoreErrorStrings = array(); |
| 236 |
foreach ($ignoreErrors as $key => $value) { |
| 237 |
if (is_string($value)) { |
| 238 |
$ignoreErrorStrings[$key] = $value; |
| 239 |
} |
| 240 |
} |
| 241 |
return $ignoreErrorStrings; |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Whether $wpdb is a usable query object for this executor's needs. |
| 246 |
* |
| 247 |
* A usable wpdb must be able to bind parameters (prepare()) and run at |
| 248 |
* least one kind of statement (get_results() for SELECTs, query() for |
| 249 |
* everything else). A non-object (null during early boot / CLI) -- or an |
| 250 |
* object that is not a real wpdb and cannot answer those calls -- must |
| 251 |
* degrade to the empty-result contract rather than fatal with |
| 252 |
* "Call to undefined method ...". A real wpdb (and every db drop-in: |
| 253 |
* HyperDB, LudicrousDB, ...) implements all of these, so this is a no-op on |
| 254 |
* a live site and only changes behavior for a malformed $wpdb. |
| 255 |
* |
| 256 |
* We require prepare() plus *either* read method rather than all three so a |
| 257 |
* legitimate read-only double (a wpdb that only ever runs SELECTs through |
| 258 |
* this executor) is not rejected, while a bare foreign object with none of |
| 259 |
* them still is. |
| 260 |
* |
| 261 |
* Uses is_callable() rather than method_exists() so it also accepts test |
| 262 |
* doubles that route methods through __call() (e.g. Mockery wpdb mocks), |
| 263 |
* while still rejecting a bare object that has neither the methods nor a |
| 264 |
* __call() handler. |
| 265 |
* |
| 266 |
* @param mixed $wpdb |
| 267 |
* @return bool |
| 268 |
*/ |
| 269 |
private function wpdbCanRunQueries($wpdb): bool { |
| 270 |
return is_object($wpdb) |
| 271 |
&& is_callable(array($wpdb, 'prepare')) |
| 272 |
&& (is_callable(array($wpdb, 'get_results')) || is_callable(array($wpdb, 'query'))); |
| 273 |
} |
| 274 |
|
| 275 |
/** |
| 276 |
* @param string $query |
| 277 |
* @param array<int|string, mixed> $queryParameters |
| 278 |
* @return string |
| 279 |
*/ |
| 280 |
private function prepareQueryParameters(string $query, array $queryParameters): string { |
| 281 |
if (empty($queryParameters)) { |
| 282 |
return $query; |
| 283 |
} |
| 284 |
|
| 285 |
global $wpdb; |
| 286 |
/** @var literal-string $queryLiteral */ |
| 287 |
$queryLiteral = $query; |
| 288 |
$orderedParameters = array_values($queryParameters); |
| 289 |
try { |
| 290 |
/** @var wpdb $wpdb */ |
| 291 |
$preparedResult = call_user_func_array(array($wpdb, 'prepare'), array_merge(array($queryLiteral), $orderedParameters)); |
| 292 |
return is_string($preparedResult) ? $preparedResult : $queryLiteral; |
| 293 |
} catch (Throwable $t) { |
| 294 |
$this->logger->debugMessage('wpdb prepare variadic call failed; retrying with array parameters.', $t); |
| 295 |
$preparedFallback = $wpdb->prepare($queryLiteral, $orderedParameters); |
| 296 |
return $preparedFallback !== null ? $preparedFallback : $queryLiteral; |
| 297 |
} |
| 298 |
} |
| 299 |
|
| 300 |
/** |
| 301 |
* @param string $query |
| 302 |
* @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType |
| 303 |
* @param bool $producesRows |
| 304 |
* @return array<string, mixed> |
| 305 |
*/ |
| 306 |
private function executeWpdbQuery(string $query, string $resultType, bool $producesRows): array { |
| 307 |
global $wpdb; |
| 308 |
if ($producesRows) { |
| 309 |
return array('rows' => $wpdb->get_results($query, $resultType)); |
| 310 |
} |
| 311 |
|
| 312 |
$wpdb->query($query); |
| 313 |
return array('rows' => array()); |
| 314 |
} |
| 315 |
|
| 316 |
} |
| 317 |
|