@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3abstract class AphrontBaseMySQLDatabaseConnection
4 extends AphrontDatabaseConnection {
5
6 private $configuration;
7 private $connection;
8 private $connectionPool = array();
9 private $lastResult;
10
11 private $nextError;
12
13 const CALLERROR_QUERY = 777777;
14 const CALLERROR_CONNECT = 777778;
15
16 abstract protected function connect();
17 abstract protected function rawQuery($raw_query);
18 abstract protected function rawQueries(array $raw_queries);
19 abstract protected function fetchAssoc($result);
20 abstract protected function getErrorCode($connection);
21 abstract protected function getErrorDescription($connection);
22 abstract protected function closeConnection();
23 abstract protected function freeResult($result);
24
25 public function __construct(array $configuration) {
26 $this->configuration = $configuration;
27 }
28
29 public function __clone() {
30 $this->establishConnection();
31 }
32
33 public function openConnection() {
34 $this->requireConnection();
35 }
36
37 public function close() {
38 if ($this->lastResult) {
39 $this->lastResult = null;
40 }
41 if ($this->connection) {
42 $this->closeConnection();
43 $this->connection = null;
44 }
45 }
46
47 public function escapeColumnName($name) {
48 return '`'.str_replace('`', '``', $name).'`';
49 }
50
51
52 public function escapeMultilineComment($comment) {
53 // These can either terminate a comment, confuse the hell out of the parser,
54 // make MySQL execute the comment as a query, or, in the case of semicolon,
55 // are quasi-dangerous because the semicolon could turn a broken query into
56 // a working query plus an ignored query.
57
58 static $map = array(
59 '--' => '(DOUBLEDASH)',
60 '*/' => '(STARSLASH)',
61 '//' => '(SLASHSLASH)',
62 '#' => '(HASH)',
63 '!' => '(BANG)',
64 ';' => '(SEMICOLON)',
65 );
66
67 $comment = str_replace(
68 array_keys($map),
69 array_values($map),
70 $comment);
71
72 // For good measure, kill anything else that isn't a nice printable
73 // character.
74 $comment = preg_replace('/[^\x20-\x7F]+/', ' ', $comment);
75
76 return '/* '.$comment.' */';
77 }
78
79 public function escapeStringForLikeClause($value) {
80 $value = phutil_string_cast($value);
81 $value = addcslashes($value, '\%_');
82 $value = $this->escapeUTF8String($value);
83 return $value;
84 }
85
86 protected function getConfiguration($key, $default = null) {
87 return idx($this->configuration, $key, $default);
88 }
89
90 private function establishConnection() {
91 $host = $this->getConfiguration('host');
92 $database = $this->getConfiguration('database');
93
94 $profiler = PhutilServiceProfiler::getInstance();
95 $call_id = $profiler->beginServiceCall(
96 array(
97 'type' => 'connect',
98 'host' => $host,
99 'database' => $database,
100 ));
101
102 // If we receive these errors, we'll retry the connection up to the
103 // retry limit. For other errors, we'll fail immediately.
104 $retry_codes = array(
105 // "Connection Timeout"
106 2002 => true,
107
108 // "Unable to Connect"
109 2003 => true,
110 );
111
112 $max_retries = max(1, $this->getConfiguration('retries', 3));
113 for ($attempt = 1; $attempt <= $max_retries; $attempt++) {
114 try {
115 $conn = $this->connect();
116 $profiler->endServiceCall($call_id, array());
117 break;
118 } catch (AphrontQueryException $ex) {
119 $code = $ex->getCode();
120 if (($attempt < $max_retries) && isset($retry_codes[$code])) {
121 $message = pht(
122 'Retrying database connection to "%s" after connection '.
123 'failure (attempt %d; "%s"; error #%d): %s',
124 $host,
125 $attempt,
126 get_class($ex),
127 $code,
128 $ex->getMessage());
129
130 // See T13403. If we're silenced with the "@" operator, don't log
131 // this connection attempt. This keeps things quiet if we're
132 // running a setup workflow like "bin/config" and expect that the
133 // database credentials will often be incorrect.
134
135 if (error_reporting()) {
136 phlog($message);
137 }
138 } else {
139 $profiler->endServiceCall($call_id, array());
140 throw $ex;
141 }
142 }
143 }
144
145 $this->connection = $conn;
146 }
147
148 protected function requireConnection() {
149 if (!$this->connection) {
150 if ($this->connectionPool) {
151 $this->connection = array_pop($this->connectionPool);
152 } else {
153 $this->establishConnection();
154 }
155 }
156 return $this->connection;
157 }
158
159 protected function beginAsyncConnection() {
160 $connection = $this->requireConnection();
161 $this->connection = null;
162 return $connection;
163 }
164
165 protected function endAsyncConnection($connection) {
166 if ($this->connection) {
167 $this->connectionPool[] = $this->connection;
168 }
169 $this->connection = $connection;
170 }
171
172 public function selectAllResults() {
173 $result = array();
174 $res = $this->lastResult;
175 if ($res == null) {
176 throw new Exception(pht('No query result to fetch from!'));
177 }
178 while (($row = $this->fetchAssoc($res))) {
179 $result[] = $row;
180 }
181 return $result;
182 }
183
184 public function executeQuery(PhutilQueryString $query) {
185 $display_query = $query->getMaskedString();
186 $raw_query = $query->getUnmaskedString();
187
188 $this->lastResult = null;
189 $retries = max(1, $this->getConfiguration('retries', 3));
190 while ($retries--) {
191 try {
192 $this->requireConnection();
193 $is_write = $this->checkWrite($raw_query);
194
195 $profiler = PhutilServiceProfiler::getInstance();
196 $call_id = $profiler->beginServiceCall(
197 array(
198 'type' => 'query',
199 'config' => $this->configuration,
200 'query' => $display_query,
201 'write' => $is_write,
202 ));
203
204 $result = $this->rawQuery($raw_query);
205
206 $profiler->endServiceCall($call_id, array());
207
208 if ($this->nextError) {
209 $result = null;
210 }
211
212 if ($result) {
213 $this->lastResult = $result;
214 break;
215 }
216
217 $this->throwQueryException($this->connection);
218 } catch (AphrontConnectionLostQueryException $ex) {
219 $can_retry = ($retries > 0);
220
221 if ($this->isInsideTransaction()) {
222 // Zero out the transaction state to prevent a second exception
223 // ("program exited with open transaction") from being thrown, since
224 // we're about to throw a more relevant/useful one instead.
225 $state = $this->getTransactionState();
226 while ($state->getDepth()) {
227 $state->decreaseDepth();
228 }
229
230 $can_retry = false;
231 }
232
233 if ($this->isHoldingAnyLock()) {
234 $this->forgetAllLocks();
235 $can_retry = false;
236 }
237
238 $this->close();
239
240 if (!$can_retry) {
241 throw $ex;
242 }
243 }
244 }
245 }
246
247 public function executeRawQueries(array $raw_queries) {
248 if (!$raw_queries) {
249 return array();
250 }
251
252 $is_write = false;
253 foreach ($raw_queries as $key => $raw_query) {
254 $is_write = $is_write || $this->checkWrite($raw_query);
255 $raw_queries[$key] = rtrim($raw_query, "\r\n\t ;");
256 }
257
258 $profiler = PhutilServiceProfiler::getInstance();
259 $call_id = $profiler->beginServiceCall(
260 array(
261 'type' => 'multi-query',
262 'config' => $this->configuration,
263 'queries' => $raw_queries,
264 'write' => $is_write,
265 ));
266
267 $results = $this->rawQueries($raw_queries);
268
269 $profiler->endServiceCall($call_id, array());
270
271 return $results;
272 }
273
274 protected function processResult($result) {
275 if (!$result) {
276 try {
277 $this->throwQueryException($this->requireConnection());
278 } catch (Exception $ex) {
279 return $ex;
280 }
281 } else if (is_bool($result)) {
282 return $this->getAffectedRows();
283 }
284 $rows = array();
285 while (($row = $this->fetchAssoc($result))) {
286 $rows[] = $row;
287 }
288 $this->freeResult($result);
289 return $rows;
290 }
291
292 protected function checkWrite($raw_query) {
293 // NOTE: The opening "(" allows queries in the form of:
294 //
295 // (SELECT ...) UNION (SELECT ...)
296 $is_write = !preg_match('/^[(]*(SELECT|SHOW|EXPLAIN)\s/', $raw_query);
297 if ($is_write) {
298 if ($this->getReadOnly()) {
299 throw new Exception(
300 pht(
301 'Attempting to issue a write query on a read-only '.
302 'connection (to database "%s")!',
303 $this->getConfiguration('database')));
304 }
305 AphrontWriteGuard::willWrite();
306 return true;
307 }
308
309 return false;
310 }
311
312 protected function throwQueryException($connection) {
313 if ($this->nextError) {
314 $errno = $this->nextError;
315 $error = pht('Simulated error.');
316 $this->nextError = null;
317 } else {
318 $errno = $this->getErrorCode($connection);
319 $error = $this->getErrorDescription($connection);
320 }
321 $this->throwQueryCodeException($errno, $error);
322 }
323
324 private function throwCommonException($errno, $error) {
325 $message = pht('#%d: %s', $errno, $error);
326
327 switch ($errno) {
328 case 2013: // Connection Dropped
329 throw new AphrontConnectionLostQueryException($message);
330 case 2006: // Gone Away
331 $more = pht(
332 'This error may occur if your configured MySQL "wait_timeout" or '.
333 '"max_allowed_packet" values are too small. This may also indicate '.
334 'that something used the MySQL "KILL <process>" command to kill '.
335 'the connection running the query.');
336 throw new AphrontConnectionLostQueryException("{$message}\n\n{$more}");
337 case 1213: // Deadlock
338 throw new AphrontDeadlockQueryException($message);
339 case 1205: // Lock wait timeout exceeded
340 throw new AphrontLockTimeoutQueryException($message);
341 case 1062: // Duplicate Key
342 // NOTE: In some versions of MySQL we get a key name back here, but
343 // older versions just give us a key index ("key 2") so it's not
344 // portable to parse the key out of the error and attach it to the
345 // exception.
346 throw new AphrontDuplicateKeyQueryException($message);
347 case 1044: // Access denied to database
348 case 1142: // Access denied to table
349 case 1143: // Access denied to column
350 case 1227: // Access denied (e.g., no SUPER for SHOW REPLICA STATUS).
351
352 // See T13622. Try to help users figure out that this is a GRANT
353 // problem.
354
355 $more = pht(
356 'This error usually indicates that you need to "GRANT" the '.
357 'MySQL user additional permissions. See "GRANT" in the MySQL '.
358 'manual for help.');
359
360 throw new AphrontAccessDeniedQueryException("{$message}\n\n{$more}");
361 case 1045: // Access denied (auth)
362 throw new AphrontInvalidCredentialsQueryException($message);
363 case 1146: // No such table
364 case 1049: // No such database
365 case 1054: // Unknown column "..." in field list
366 throw new AphrontSchemaQueryException($message);
367 }
368
369 // TODO: 1064 is syntax error, and quite terrible in production.
370
371 return null;
372 }
373
374 protected function throwConnectionException($errno, $error, $user, $host) {
375 $this->throwCommonException($errno, $error);
376
377 $message = pht(
378 'Attempt to connect to %s@%s failed with error #%d: %s.',
379 $user,
380 $host,
381 $errno,
382 $error);
383
384 throw new AphrontConnectionQueryException($message, $errno);
385 }
386
387
388 protected function throwQueryCodeException($errno, $error) {
389 $this->throwCommonException($errno, $error);
390
391 $message = pht(
392 '#%d: %s',
393 $errno,
394 $error);
395
396 throw new AphrontQueryException($message, $errno);
397 }
398
399 /**
400 * Force the next query to fail with a simulated error. This should be used
401 * ONLY for unit tests.
402 */
403 public function simulateErrorOnNextQuery($error) {
404 $this->nextError = $error;
405 return $this;
406 }
407
408 /**
409 * Check inserts for characters outside of the BMP. Even with the strictest
410 * settings, MySQL will silently truncate data when it encounters these, which
411 * can lead to data loss and security problems.
412 */
413 protected function validateUTF8String($string) {
414 if (phutil_is_utf8($string)) {
415 return;
416 }
417
418 throw new AphrontCharacterSetQueryException(
419 pht(
420 'Attempting to construct a query using a non-utf8 string when '.
421 'utf8 is expected. Use the `%%B` conversion to escape binary '.
422 'strings data.'));
423 }
424
425}