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

317 lines 12.5 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 /**
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