Issues (10)

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 6920
	public function __construct(DbConfig $db_config, QueryCache $query_cache, Config $config) {
79 6920
		$this->query_cache = $query_cache;
80 6920
		$this->config = $config;
81
		
82 6920
		$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 6920
	public function resetConnections(DbConfig $config) {
93 6920
		$this->closeConnections();
94
		
95 6920
		$this->db_config = $config;
96 6920
		$this->table_prefix = $config->getTablePrefix();
97 6920
		$this->query_cache->enable();
98 6920
		$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 8369
	public function closeConnections(): void {
110 8369
		foreach ($this->connections as $connection) {
111 3225
			$connection->close();
112
		}
113
		
114 8369
		$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 3227
	public function getConnection(string $type): Connection {
125 3227
		if (isset($this->connections[$type])) {
126 5
			return $this->connections[$type];
127 3227
		} else if (isset($this->connections['readwrite'])) {
128 3227
			return $this->connections['readwrite'];
129
		}
130
		
131 3221
		$this->setupConnections();
132
		
133 3221
		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 3221
	public function setupConnections(): void {
145 3221
		if ($this->db_config->isDatabaseSplit()) {
146
			$this->connect('read');
147
			$this->connect('write');
148
		} else {
149 3221
			$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 3221
	public function connect(string $type = 'readwrite'): void {
164 3221
		$conf = $this->db_config->getConnectionConfig($type);
165
166 3221
		$params = [
167 3221
			'dbname' => $conf['database'],
168 3221
			'user' => $conf['user'],
169 3221
			'password' => $conf['password'],
170 3221
			'host' => $conf['host'],
171 3221
			'port' => $conf['port'],
172 3221
			'charset' => $conf['encoding'],
173 3221
			'driver' => 'pdo_mysql',
174 3221
		];
175
176
		try {
177 3221
			$this->connections[$type] = DriverManager::getConnection($params);
178
179
			// https://github.com/Elgg/Elgg/issues/8121
180 3221
			$sub_query = "SELECT REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', '')";
181 3221
			$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 3460
	public function getData(QueryBuilder $query, $callback = null) {
212 3460
		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 1766
	public function getDataRow(QueryBuilder $query, $callback = null) {
228 1766
		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 1406
	public function insertData(QueryBuilder $query): int {
241
242 1406
		$params = $query->getParameters();
243 1406
		$sql = $query->getSQL();
244
		
245 1406
		$this->getLogger()->info("DB insert query {$sql} (params: " . print_r($params, true) . ')');
246
247 1406
		$this->query_cache->clear();
248
249 1406
		$this->executeQuery($query);
250 1405
		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 1450
	public function updateData(QueryBuilder $query, bool $get_num_rows = false) {
264 1450
		$params = $query->getParameters();
265 1450
		$sql = $query->getSQL();
266
	
267 1450
		$this->getLogger()->info("DB update query {$sql} (params: " . print_r($params, true) . ')');
268
269 1450
		$this->query_cache->clear();
270
271 1450
		$result = $this->executeQuery($query);
272 1449
		if (!$get_num_rows) {
273 1447
			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 1347
	public function deleteData(QueryBuilder $query): int {
289 1347
		$params = $query->getParameters();
290 1347
		$sql = $query->getSQL();
291
292 1347
		$this->getLogger()->info("DB delete query {$sql} (params: " . print_r($params, true) . ')');
293
294 1347
		$this->query_cache->clear();
295
296 1347
		$result = $this->executeQuery($query);
297 1346
		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 1863
	protected function fingerprintCallback($callback): string {
312 1863
		if (is_string($callback)) {
313 1
			return $callback;
314
		}
315
316 1863
		if (is_object($callback)) {
317 1617
			return spl_object_hash($callback) . '::__invoke';
318
		}
319
320 1829
		if (is_array($callback)) {
321 1829
			if (is_string($callback[0])) {
322 1
				return "{$callback[0]}::{$callback[1]}";
323
			}
324
325 1829
			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 3562
	protected function getResults(QueryBuilder $query, $callback = null, bool $single = false) {
345 3562
		$params = $query->getParameters();
346 3562
		$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 3562
		$extras = (int) $single . '|';
352 3562
		if ($callback) {
353 1863
			if (!is_callable($callback)) {
354 1
				throw new RuntimeException('$callback must be a callable function. Given ' . _elgg_services()->handlers->describeCallable($callback));
355
			}
356
			
357 1862
			$extras .= $this->fingerprintCallback($callback);
358
		}
359
		
360 3561
		$hash = $this->query_cache->getHash($sql, $params, $extras);
361
362 3561
		$cached_results = $this->query_cache->get($hash);
363 3561
		if (isset($cached_results)) {
364 1371
			return $cached_results;
365
		}
366
		
367 3545
		$this->getLogger()->info("DB select query {$sql} (params: " . print_r($params, true) . ')');
368
		
369 3545
		$return = [];
370
371 3545
		$stmt = $this->executeQuery($query);
372
		
373 3545
		while ($row = $stmt->fetchAssociative()) {
374 3304
			$row_obj = (object) $row;
375 3304
			if ($callback) {
376 1641
				$row_obj = call_user_func($callback, $row_obj);
377
			}
378
379 3304
			if ($single) {
380 1668
				$return = $row_obj;
381 1668
				break;
382
			} else {
383 3218
				$return[] = $row_obj;
384
			}
385
		}
386
387
		// Cache result
388 3545
		$this->query_cache->set($hash, $return);
389
				
390 3545
		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 3600
	protected function executeQuery(QueryBuilder $query) {
405
		
406
		try {
407 3600
			$result = $this->trackQuery($query, function() use ($query) {
408 3600
				if ($query instanceof \Elgg\Database\Select) {
409 3545
					return $query->executeQuery();
410
				} else {
411 1677
					return $query->executeStatement();
412
				}
413 3600
			});
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 3597
		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 3600
	public function trackQuery(QueryBuilder $query, callable $callback) {
434
435 3600
		$params = $query->getParameters();
436 3600
		$sql = $query->getSQL();
437
438 3600
		$this->query_count++;
439
440 3600
		$timer_key = preg_replace('~\\s+~', ' ', trim($sql . '|' . serialize($params)));
441 3600
		$this->beginTimer(['SQL', $timer_key]);
442
		
443 3600
		$stop_timer = function() use ($timer_key) {
444 3600
			$this->endTimer(['SQL', $timer_key]);
445 3600
		};
446
		
447
		try {
448 3600
			$result = $callback();
449 23
		} catch (\Exception $e) {
450 23
			$stop_timer();
451
			
452 23
			throw $e;
453
		}
454
		
455 3597
		$stop_timer();
456
		
457 3597
		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 3847
	public function __get($name) {
606 3847
		if ($name === 'prefix') {
607 3847
			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