Passed
Push — 5.x ( 39b1e8...2ac3d7 )
by Jeroen
15:45 queued 13s
created

engine/classes/Elgg/Database.php (1 issue)

1
<?php
2
3
namespace Elgg;
4
5
use Doctrine\DBAL\Connection;
6
use Doctrine\DBAL\DriverManager;
7
use Doctrine\DBAL\Driver\ServerInfoAwareConnection;
8
use Doctrine\DBAL\Result;
9
use Doctrine\DBAL\Query\QueryBuilder;
10
use Elgg\Cache\QueryCache;
11
use Elgg\Database\DbConfig;
12
use Elgg\Exceptions\DatabaseException;
13
use Elgg\Exceptions\RuntimeException;
14
use Elgg\Traits\Debug\Profilable;
15
use Elgg\Traits\Loggable;
16
use Psr\Log\LogLevel;
17
18
/**
19
 * The Elgg database
20
 *
21
 * @internal
22
 *
23
 * @property-read string $prefix Elgg table prefix (read only)
24
 */
25
class Database {
26
	
27
	use Profilable;
28
	use Loggable;
29
30
	const DELAYED_QUERY = 'q';
31
	const DELAYED_HANDLER = 'h';
32
33
	/**
34
	 * @var string $table_prefix Prefix for database tables
35
	 */
36
	private $table_prefix;
37
38
	/**
39
	 * @var Connection[]
40
	 */
41
	private $connections = [];
42
43
	/**
44
	 * @var int $query_count The number of queries made
45
	 */
46
	private $query_count = 0;
47
48
	/**
49
	 * Query cache for select queries.
50
	 *
51
	 * @var \Elgg\Cache\QueryCache $query_cache The cache
52
	 */
53
	protected $query_cache;
54
55
	/**
56
	 * Queries are saved as an array with the DELAYED_* constants as keys.
57
	 *
58
	 * @see registerDelayedQuery()
59
	 *
60
	 * @var array $delayed_queries Queries to be run during shutdown
61
	 */
62
	protected $delayed_queries = [];
63
64
	/**
65
	 * @var \Elgg\Database\DbConfig $config Database configuration
66
	 */
67
	private $db_config;
68
	
69
	protected Config $config;
70
71
	/**
72
	 * Constructor
73
	 *
74
	 * @param DbConfig   $db_config   DB configuration
75
	 * @param QueryCache $query_cache Query Cache
76
	 * @param Config     $config      Elgg config
77
	 */
78 6895
	public function __construct(DbConfig $db_config, QueryCache $query_cache, Config $config) {
79 6895
		$this->query_cache = $query_cache;
80 6895
		$this->config = $config;
81
		
82 6895
		$this->resetConnections($db_config);
83
	}
84
85
	/**
86
	 * Reset the connections with new credentials
87
	 *
88
	 * @param DbConfig $config DB config
89
	 *
90
	 * @return void
91
	 */
92 6895
	public function resetConnections(DbConfig $config) {
93 6895
		$this->closeConnections();
94
		
95 6895
		$this->db_config = $config;
96 6895
		$this->table_prefix = $config->getTablePrefix();
97 6895
		$this->query_cache->enable();
98 6895
		$this->query_cache->clear();
99
	}
100
	
101
	/**
102
	 * Close all database connections
103
	 *
104
	 * Note: this is only meant to be used in the PHPUnit test suites
105
	 *
106
	 * @return void
107
	 * @since 4.1
108
	 */
109 8342
	public function closeConnections(): void {
110 8342
		foreach ($this->connections as $connection) {
111 3208
			$connection->close();
112
		}
113
		
114 8342
		$this->connections = [];
115
	}
116
117
	/**
118
	 * Gets (if required, also creates) a DB connection.
119
	 *
120
	 * @param string $type The type of link we want: "read", "write" or "readwrite".
121
	 *
122
	 * @return Connection
123
	 */
124 3210
	public function getConnection(string $type): Connection {
125 3210
		if (isset($this->connections[$type])) {
126 5
			return $this->connections[$type];
127 3210
		} else if (isset($this->connections['readwrite'])) {
128 3210
			return $this->connections['readwrite'];
129
		}
130
		
131 3204
		$this->setupConnections();
132
		
133 3204
		return $this->getConnection($type);
134
	}
135
136
	/**
137
	 * Establish database connections
138
	 *
139
	 * If the configuration has been set up for multiple read/write databases, set those
140
	 * links up separately; otherwise just create the one database link.
141
	 *
142
	 * @return void
143
	 */
144 3204
	public function setupConnections(): void {
145 3204
		if ($this->db_config->isDatabaseSplit()) {
146
			$this->connect('read');
147
			$this->connect('write');
148
		} else {
149 3204
			$this->connect('readwrite');
150
		}
151
	}
152
153
	/**
154
	 * Establish a connection to the database server
155
	 *
156
	 * Connect to the database server and use the Elgg database for a particular database link
157
	 *
158
	 * @param string $type The type of database connection. "read", "write", or "readwrite".
159
	 *
160
	 * @return void
161
	 * @throws DatabaseException
162
	 */
163 3204
	public function connect(string $type = 'readwrite'): void {
164 3204
		$conf = $this->db_config->getConnectionConfig($type);
165
166 3204
		$params = [
167 3204
			'dbname' => $conf['database'],
168 3204
			'user' => $conf['user'],
169 3204
			'password' => $conf['password'],
170 3204
			'host' => $conf['host'],
171 3204
			'port' => $conf['port'],
172 3204
			'charset' => $conf['encoding'],
173 3204
			'driver' => 'pdo_mysql',
174 3204
		];
175
176
		try {
177 3204
			$this->connections[$type] = DriverManager::getConnection($params);
178
179
			// https://github.com/Elgg/Elgg/issues/8121
180 3204
			$sub_query = "SELECT REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '')";
181 3204
			$this->connections[$type]->executeStatement("SET SESSION sql_mode=($sub_query);");
182
		} catch (\Exception $e) {
183
			// http://dev.mysql.com/doc/refman/5.1/en/error-messages-server.html
184
			$this->log(LogLevel::ERROR, $e);
185
186
			if ($e->getCode() == 1102 || $e->getCode() == 1049) {
187
				$msg = "Elgg couldn't select the database '{$conf['database']}'. Please check that the database is created and you have access to it.";
188
			} else {
189
				$msg = "Elgg couldn't connect to the database using the given credentials. Check the settings file.";
190
			}
191
			
192
			throw new DatabaseException($msg);
193
		}
194
	}
195
196
	/**
197
	 * Retrieve rows from the database.
198
	 *
199
	 * Queries are executed with {@link \Elgg\Database::executeQuery()} and results
200
	 * are retrieved with {@link \PDO::fetchObject()}.  If a callback
201
	 * function $callback is defined, each row will be passed as a single
202
	 * argument to $callback.  If no callback function is defined, the
203
	 * entire result set is returned as an array.
204
	 *
205
	 * @param QueryBuilder $query    The query being passed.
206
	 * @param callable     $callback Optionally, the name of a function to call back to on each row
207
	 *
208
	 * @return array An array of database result objects or callback function results. If the query
209
	 *               returned nothing, an empty array.
210
	 */
211 3444
	public function getData(QueryBuilder $query, $callback = null) {
212 3444
		return $this->getResults($query, $callback, false);
213
	}
214
215
	/**
216
	 * Retrieve a single row from the database.
217
	 *
218
	 * Similar to {@link \Elgg\Database::getData()} but returns only the first row
219
	 * matched.  If a callback function $callback is specified, the row will be passed
220
	 * as the only argument to $callback.
221
	 *
222
	 * @param QueryBuilder $query    The query to execute.
223
	 * @param callable     $callback A callback function to apply to the row
224
	 *
225
	 * @return mixed A single database result object or the result of the callback function.
226
	 */
227 1763
	public function getDataRow(QueryBuilder $query, $callback = null) {
228 1763
		return $this->getResults($query, $callback, true);
229
	}
230
231
	/**
232
	 * Insert a row into the database.
233
	 *
234
	 * @note Altering the DB invalidates all queries in the query cache.
235
	 *
236
	 * @param QueryBuilder $query The query to execute.
237
	 *
238
	 * @return int The database id of the inserted row if a AUTO_INCREMENT field is defined, 0 if not
239
	 */
240 1404
	public function insertData(QueryBuilder $query): int {
241
242 1404
		$params = $query->getParameters();
243 1404
		$sql = $query->getSQL();
244
		
245 1404
		$this->getLogger()->info("DB insert query {$sql} (params: " . print_r($params, true) . ')');
246
247 1404
		$this->query_cache->clear();
248
249 1404
		$this->executeQuery($query);
250 1403
		return (int) $query->getConnection()->lastInsertId();
251
	}
252
253
	/**
254
	 * Update the database.
255
	 *
256
	 * @note Altering the DB invalidates all queries in the query cache.
257
	 *
258
	 * @param QueryBuilder $query        The query to run.
259
	 * @param bool         $get_num_rows Return the number of rows affected (default: false).
260
	 *
261
	 * @return bool|int
262
	 */
263 1446
	public function updateData(QueryBuilder $query, bool $get_num_rows = false) {
264 1446
		$params = $query->getParameters();
265 1446
		$sql = $query->getSQL();
266
	
267 1446
		$this->getLogger()->info("DB update query {$sql} (params: " . print_r($params, true) . ')');
268
269 1446
		$this->query_cache->clear();
270
271 1446
		$result = $this->executeQuery($query);
272 1445
		if (!$get_num_rows) {
273 1443
			return true;
274
		}
275
276 5
		return ($result instanceof Result) ? $result->rowCount() : $result;
277
	}
278
279
	/**
280
	 * Delete data from the database
281
	 *
282
	 * @note Altering the DB invalidates all queries in query cache.
283
	 *
284
	 * @param QueryBuilder $query The SQL query to run
285
	 *
286
	 * @return int The number of affected rows
287
	 */
288 1343
	public function deleteData(QueryBuilder $query): int {
289 1343
		$params = $query->getParameters();
290 1343
		$sql = $query->getSQL();
291
292 1343
		$this->getLogger()->info("DB delete query {$sql} (params: " . print_r($params, true) . ')');
293
294 1343
		$this->query_cache->clear();
295
296 1343
		$result = $this->executeQuery($query);
297 1342
		return ($result instanceof Result) ? $result->rowCount() : $result;
298
	}
299
300
	/**
301
	 * Get a string that uniquely identifies a callback during the current request.
302
	 *
303
	 * This is used to cache queries whose results were transformed by the callback. If the callback involves
304
	 * object method calls of the same class, different instances will return different values.
305
	 *
306
	 * @param callable $callback The callable value to fingerprint
307
	 *
308
	 * @return string A string that is unique for each callable passed in
309
	 * @since 1.9.4
310
	 */
311 1859
	protected function fingerprintCallback($callback): string {
312 1859
		if (is_string($callback)) {
313 1
			return $callback;
314
		}
315
316 1859
		if (is_object($callback)) {
317 1613
			return spl_object_hash($callback) . '::__invoke';
318
		}
319
320 1825
		if (is_array($callback)) {
321 1825
			if (is_string($callback[0])) {
322 1
				return "{$callback[0]}::{$callback[1]}";
323
			}
324
325 1825
			return spl_object_hash($callback[0]) . "::{$callback[1]}";
326
		}
327
328
		// this should not happen
329
		return '';
330
	}
331
332
	/**
333
	 * Handles queries that return results, running the results through a
334
	 * an optional callback function. This is for R queries (from CRUD).
335
	 *
336
	 * @param QueryBuilder $query    The select query to execute
337
	 * @param callable     $callback An optional callback function to run on each row
338
	 * @param bool         $single   Return only a single result?
339
	 *
340
	 * @return array|\stdClass An array of database result objects or callback function results. If the query
341
	 *               returned nothing, an empty array.
342
	 * @throws RuntimeException
343
	 */
344 3545
	protected function getResults(QueryBuilder $query, $callback = null, bool $single = false) {
345 3545
		$params = $query->getParameters();
346 3545
		$sql = $query->getSQL();
347
		
348
		// Since we want to cache results of running the callback, we need to
349
		// namespace the query with the callback and single result request.
350
		// https://github.com/elgg/elgg/issues/4049
351 3545
		$extras = (int) $single . '|';
352 3545
		if ($callback) {
353 1859
			if (!is_callable($callback)) {
354 1
				throw new RuntimeException('$callback must be a callable function. Given ' . _elgg_services()->handlers->describeCallable($callback));
355
			}
356
			
357 1858
			$extras .= $this->fingerprintCallback($callback);
358
		}
359
		
360 3544
		$hash = $this->query_cache->getHash($sql, $params, $extras);
361
362 3544
		$cached_results = $this->query_cache->get($hash);
363 3544
		if (isset($cached_results)) {
364 1368
			return $cached_results;
365
		}
366
		
367 3529
		$this->getLogger()->info("DB select query {$sql} (params: " . print_r($params, true) . ')');
368
		
369 3529
		$return = [];
370
371 3529
		$stmt = $this->executeQuery($query);
372
		
373 3529
		while ($row = $stmt->fetchAssociative()) {
374 3290
			$row_obj = (object) $row;
375 3290
			if ($callback) {
376 1640
				$row_obj = call_user_func($callback, $row_obj);
377
			}
378
379 3290
			if ($single) {
380 1666
				$return = $row_obj;
381 1666
				break;
382
			} else {
383 3203
				$return[] = $row_obj;
384
			}
385
		}
386
387
		// Cache result
388 3529
		$this->query_cache->set($hash, $return);
389
				
390 3529
		return $return;
391
	}
392
393
	/**
394
	 * Execute a query.
395
	 *
396
	 * $query is executed via {@link Connection::query}. If there is an SQL error,
397
	 * a {@link DatabaseException} is thrown.
398
	 *
399
	 * @param QueryBuilder $query The query
400
	 *
401
	 * @return Result|int The result of the query
402
	 * @throws DatabaseException
403
	 */
404 3584
	protected function executeQuery(QueryBuilder $query) {
405
		
406
		try {
407 3584
			$result = $this->trackQuery($query, function() use ($query) {
408 3584
				if ($query instanceof \Elgg\Database\Select) {
409 3529
					return $query->executeQuery();
410
				} else {
411 1673
					return $query->executeStatement();
412
				}
413 3584
			});
414 23
		} catch (\Exception $e) {
415 23
			$ex = new DatabaseException($e->getMessage(), 0, $e);
416 23
			$ex->setParameters($query->getParameters());
417 23
			$ex->setQuery($query->getSQL());
418
419 23
			throw $ex;
420
		}
421
422 3581
		return $result;
423
	}
424
	
425
	/**
426
	 * Tracks the query count and timers for a given query
427
	 *
428
	 * @param QueryBuilder $query    The query
429
	 * @param callable     $callback Callback to execyte during query execution
430
	 *
431
	 * @return mixed
432
	 */
433 3584
	public function trackQuery(QueryBuilder $query, callable $callback) {
434
435 3584
		$params = $query->getParameters();
436 3584
		$sql = $query->getSQL();
437
438 3584
		$this->query_count++;
439
440 3584
		$timer_key = preg_replace('~\\s+~', ' ', trim($sql . '|' . serialize($params)));
441 3584
		$this->beginTimer(['SQL', $timer_key]);
442
		
443 3584
		$stop_timer = function() use ($timer_key) {
444 3584
			$this->endTimer(['SQL', $timer_key]);
445 3584
		};
446
		
447
		try {
448 3584
			$result = $callback();
449 23
		} catch (\Exception $e) {
450 23
			$stop_timer();
451
			
452 23
			throw $e;
453
		}
454
		
455 3581
		$stop_timer();
456
		
457 3581
		return $result;
458
	}
459
460
	/**
461
	 * Queue a query for execution upon shutdown.
462
	 *
463
	 * You can specify a callback if you care about the result. This function will always
464
	 * be passed a \Doctrine\DBAL\Driver\Statement.
465
	 *
466
	 * @param QueryBuilder $query    The query to execute
467
	 * @param callable     $callback A callback function to pass the results array to
468
	 *
469
	 * @return void
470
	 */
471 8
	public function registerDelayedQuery(QueryBuilder $query, $callback = null): void {
472 8
		if (Application::isCli() && !$this->config->testing_mode) {
473
			// during CLI execute delayed queries immediately (unless in testing mode, during PHPUnit)
474
			// this should prevent OOM during long-running jobs
475
			// @see Database::executeDelayedQueries()
476
			try {
477
				$stmt = $this->executeQuery($query);
478
				
479
				if (is_callable($callback)) {
480
					call_user_func($callback, $stmt);
481
				}
482
			} catch (\Throwable $t) {
483
				// Suppress all exceptions to not allow the application to crash
484
				$this->getLogger()->error($t);
485
			}
486
			
487
			return;
488
		}
489
		
490 8
		$this->delayed_queries[] = [
491 8
			self::DELAYED_QUERY => $query,
492 8
			self::DELAYED_HANDLER => $callback,
493 8
		];
494
	}
495
496
	/**
497
	 * Trigger all queries that were registered as "delayed" queries. This is
498
	 * called by the system automatically on shutdown.
499
	 *
500
	 * @return void
501
	 */
502 4
	public function executeDelayedQueries(): void {
503
504 4
		foreach ($this->delayed_queries as $set) {
505 3
			$query = $set[self::DELAYED_QUERY];
506 3
			$handler = $set[self::DELAYED_HANDLER];
507
508
			try {
509 3
				$stmt = $this->executeQuery($query);
510
511 3
				if (is_callable($handler)) {
512 3
					call_user_func($handler, $stmt);
513
				}
514
			} catch (\Throwable $t) {
515
				// Suppress all exceptions since page already sent to requestor
516
				$this->getLogger()->error($t);
517
			}
518
		}
519
520 4
		$this->delayed_queries = [];
521
	}
522
523
	/**
524
	 * Enable the query cache
525
	 *
526
	 * This does not take precedence over the \Elgg\Database\Config setting.
527
	 *
528
	 * @return void
529
	 */
530
	public function enableQueryCache(): void {
531
		$this->query_cache->enable();
532
	}
533
534
	/**
535
	 * Disable the query cache
536
	 *
537
	 * This is useful for special scripts that pull large amounts of data back
538
	 * in single queries.
539
	 *
540
	 * @return void
541
	 */
542
	public function disableQueryCache(): void {
543
		$this->query_cache->disable();
544
	}
545
546
	/**
547
	 * Get the number of queries made to the database
548
	 *
549
	 * @return int
550
	 */
551 1
	public function getQueryCount(): int {
552 1
		return $this->query_count;
553
	}
554
555
	/**
556
	 * Get the server version number
557
	 *
558
	 * @param string $type Connection type (Config constants, e.g. Config::READ_WRITE)
559
	 *
560
	 * @return string Empty if version cannot be determined
561
	 */
562 5
	public function getServerVersion(string $type = DbConfig::READ_WRITE): string {
563 5
		$driver = $this->getConnection($type)->getWrappedConnection();
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::getWrappedConnection() has been deprecated: Use {@link getNativeConnection()} to access the native connection. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

563
		$driver = /** @scrutinizer ignore-deprecated */ $this->getConnection($type)->getWrappedConnection();

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
564 5
		if ($driver instanceof ServerInfoAwareConnection) {
565 5
			$version = $driver->getServerVersion();
566
			
567 5
			if ($this->isMariaDB($type)) {
568
				if (str_starts_with($version, '5.5.5-')) {
569
					$version = substr($version, 6);
570
				}
571
			}
572
			
573 5
			return $version;
574
		}
575
576
		return '';
577
	}
578
579
	/**
580
	 * Is the database MariaDB
581
	 *
582
	 * @param string $type Connection type (Config constants, e.g. Config::READ_WRITE)
583
	 *
584
	 * @return bool if MariaDB is detected
585
	 */
586 5
	public function isMariaDB(string $type = DbConfig::READ_WRITE): bool {
587 5
		$driver = $this->getConnection($type)->getWrappedConnection();
588 5
		if ($driver instanceof ServerInfoAwareConnection) {
589 5
			$version = $driver->getServerVersion();
590
			
591 5
			return stristr($version, 'mariadb') !== false;
592
		}
593
594
		return false;
595
	}
596
	
597
	/**
598
	 * Handle magic property reads
599
	 *
600
	 * @param string $name Property name
601
	 *
602
	 * @return mixed
603
	 * @throws RuntimeException
604
	 */
605 3830
	public function __get($name) {
606 3830
		if ($name === 'prefix') {
607 3830
			return $this->table_prefix;
608
		}
609
610
		throw new RuntimeException("Cannot read property '{$name}'");
611
	}
612
613
	/**
614
	 * Handle magic property writes
615
	 *
616
	 * @param string $name  Property name
617
	 * @param mixed  $value Value
618
	 *
619
	 * @return void
620
	 * @throws RuntimeException
621
	 */
622
	public function __set($name, $value): void {
623
		throw new RuntimeException("Cannot write property '{$name}'");
624
	}
625
}
626