qa-db.php 24.7 KB
Newer Older
Scott committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
<?php
/*
	Question2Answer by Gideon Greenspan and contributors
	http://www.question2answer.org/

	Description: Common functions for connecting to and accessing database


	This program is free software; you can redistribute it and/or
	modify it under the terms of the GNU General Public License
	as published by the Free Software Foundation; either version 2
	of the License, or (at your option) any later version.

	This program is distributed in the hope that it will be useful,
	but WITHOUT ANY WARRANTY; without even the implied warranty of
	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
	GNU General Public License for more details.

	More about this license: http://www.question2answer.org/license.php
*/

Scott committed
22 23 24 25
if (!defined('QA_VERSION')) { // don't allow this page to be requested directly from browser
	header('Location: ../');
	exit;
}
Scott committed
26 27


Scott committed
28 29 30 31 32 33 34
/**
 * Indicates to the Q2A database layer that database connections are permitted fro this point forwards
 * (before this point, some plugins may not have had a chance to override some database access functions).
 */
function qa_db_allow_connect()
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }
Scott committed
35

Scott committed
36
	global $qa_db_allow_connect;
Scott committed
37

Scott committed
38 39
	$qa_db_allow_connect = true;
}
40 41


Scott committed
42 43 44 45 46 47 48 49 50
/**
 * Connect to the Q2A database, select the right database, optionally install the $failhandler (and call it if necessary).
 * Uses mysqli as of Q2A 1.7.
 * @param null $failhandler
 * @return mixed|void
 */
function qa_db_connect($failhandler = null)
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }
Scott committed
51

Scott committed
52
	global $qa_db_connection, $qa_db_fail_handler, $qa_db_allow_connect;
Scott committed
53

Scott committed
54 55
	if (!$qa_db_allow_connect)
		qa_fatal_error('It appears that a plugin is trying to access the database, but this is not allowed until Q2A initialization is complete.');
Scott committed
56

Scott committed
57 58
	if (isset($failhandler))
		$qa_db_fail_handler = $failhandler; // set this even if connection already opened
Scott committed
59

Scott committed
60 61
	if ($qa_db_connection instanceof mysqli)
		return;
Scott committed
62

Scott committed
63 64
	$host = QA_FINAL_MYSQL_HOSTNAME;
	$port = null;
Scott committed
65

Scott committed
66 67 68 69 70 71
	if (defined('QA_FINAL_WORDPRESS_INTEGRATE_PATH')) {
		// Wordpress allows setting port inside DB_HOST constant, like 127.0.0.1:3306
		$host_and_port = explode(':', $host);
		if (count($host_and_port) >= 2) {
			$host = $host_and_port[0];
			$port = $host_and_port[1];
Scott committed
72
		}
Scott committed
73 74
	} elseif (defined('QA_FINAL_MYSQL_PORT')) {
		$port = QA_FINAL_MYSQL_PORT;
Scott committed
75 76
	}

Scott committed
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
	if (QA_PERSISTENT_CONN_DB)
		$host = 'p:' . $host;

	// in mysqli we connect and select database in constructor
	if ($port !== null)
		$db = new mysqli($host, QA_FINAL_MYSQL_USERNAME, QA_FINAL_MYSQL_PASSWORD, QA_FINAL_MYSQL_DATABASE, $port);
	else
		$db = new mysqli($host, QA_FINAL_MYSQL_USERNAME, QA_FINAL_MYSQL_PASSWORD, QA_FINAL_MYSQL_DATABASE);

	// must use procedural `mysqli_connect_error` here prior to 5.2.9
	$conn_error = mysqli_connect_error();
	if ($conn_error)
		qa_db_fail_error('connect', $db->connect_errno, $conn_error);

	// From Q2A 1.5, we explicitly set the character encoding of the MySQL connection, instead of using lots of "SELECT BINARY col"-style queries.
	// Testing showed that overhead is minimal, so this seems worth trading off against the benefit of more straightforward queries, especially
	// for plugin developers.
	if (!$db->set_charset('utf8'))
		qa_db_fail_error('set_charset', $db->errno, $db->error);

	qa_report_process_stage('db_connected');

	$qa_db_connection = $db;
}


/**
 * If a DB error occurs, call the installed fail handler (if any) otherwise report error and exit immediately.
 * @param $type
 * @param int $errno
 * @param string $error
 * @param string $query
 * @return mixed
 */
function qa_db_fail_error($type, $errno = null, $error = null, $query = null)
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }

	global $qa_db_fail_handler;

	@error_log('PHP Question2Answer MySQL ' . $type . ' error ' . $errno . ': ' . $error . (isset($query) ? (' - Query: ' . $query) : ''));

	if (function_exists($qa_db_fail_handler))
		$qa_db_fail_handler($type, $errno, $error, $query);
	else {
		echo sprintf(
			'<hr><div style="color: red">Database %s<p>%s</p><code>%s</code></div>',
			htmlspecialchars($type . ' error ' . $errno), nl2br(htmlspecialchars($error)), nl2br(htmlspecialchars($query))
		);
		qa_exit('error');
Scott committed
127
	}
Scott committed
128
}
Scott committed
129 130


Scott committed
131 132 133 134 135 136 137 138
/**
 * Return the current connection to the Q2A database, connecting if necessary and $connect is true.
 * @param bool $connect
 * @return mixed
 */
function qa_db_connection($connect = true)
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }
Scott committed
139

Scott committed
140
	global $qa_db_connection;
Scott committed
141

Scott committed
142 143
	if ($connect && !($qa_db_connection instanceof mysqli)) {
		qa_db_connect();
Scott committed
144

Scott committed
145 146
		if (!($qa_db_connection instanceof mysqli))
			qa_fatal_error('Failed to connect to database');
Scott committed
147 148
	}

Scott committed
149 150
	return $qa_db_connection;
}
Scott committed
151 152


Scott committed
153 154 155 156 157 158
/**
 * Disconnect from the Q2A database.
 */
function qa_db_disconnect()
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }
Scott committed
159

Scott committed
160
	global $qa_db_connection;
Scott committed
161

Scott committed
162 163
	if ($qa_db_connection instanceof mysqli) {
		qa_report_process_stage('db_disconnect');
Scott committed
164

Scott committed
165 166 167
		if (!QA_PERSISTENT_CONN_DB) {
			if (!$qa_db_connection->close())
				qa_fatal_error('Database disconnect failed');
Scott committed
168 169
		}

Scott committed
170
		$qa_db_connection = null;
Scott committed
171
	}
Scott committed
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
}


/**
 * Run the raw $query, call the global failure handler if necessary, otherwise return the result resource.
 * If appropriate, also track the resources used by database queries, and the queries themselves, for performance debugging.
 * @param $query
 * @return mixed
 */
function qa_db_query_raw($query)
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }

	if (QA_DEBUG_PERFORMANCE) {
		global $qa_usage;

		// time the query
		$oldtime = array_sum(explode(' ', microtime()));
		$result = qa_db_query_execute($query);
		$usedtime = array_sum(explode(' ', microtime())) - $oldtime;

		// fetch counts
		$gotrows = $gotcolumns = null;
		if ($result instanceof mysqli_result) {
			$gotrows = $result->num_rows;
			$gotcolumns = $result->field_count;
Scott committed
198 199
		}

Scott committed
200 201 202
		$qa_usage->logDatabaseQuery($query, $usedtime, $gotrows, $gotcolumns);
	} else
		$result = qa_db_query_execute($query);
Scott committed
203

Scott committed
204
	// @error_log('Question2Answer MySQL query: '.$query);
Scott committed
205

Scott committed
206
	if ($result === false) {
Scott committed
207
		$db = qa_db_connection();
Scott committed
208
		qa_db_fail_error('query', $db->errno, $db->error, $query);
Scott committed
209 210
	}

Scott committed
211 212
	return $result;
}
Scott committed
213 214


Scott committed
215 216 217 218 219 220 221 222
/**
 * Lower-level function to execute a query, which automatically retries if there is a MySQL deadlock error.
 * @param $query
 * @return mixed
 */
function qa_db_query_execute($query)
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }
Scott committed
223

Scott committed
224
	$db = qa_db_connection();
Scott committed
225

Scott committed
226 227
	for ($attempt = 0; $attempt < 100; $attempt++) {
		$result = $db->query($query);
Scott committed
228

Scott committed
229 230 231 232
		if ($result === false && $db->errno == 1213)
			usleep(10000); // deal with InnoDB deadlock errors by waiting 0.01s then retrying
		else
			break;
Scott committed
233 234
	}

Scott committed
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272
	return $result;
}


/**
 * Return $string escaped for use in queries to the Q2A database (to which a connection must have been made).
 * @param $string
 * @return mixed
 */
function qa_db_escape_string($string)
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }

	$db = qa_db_connection();
	return $db->real_escape_string($string);
}


/**
 * Return $argument escaped for MySQL. Add quotes around it if $alwaysquote is true or it's not numeric.
 * If $argument is an array, return a comma-separated list of escaped elements, with or without $arraybrackets.
 * @param $argument
 * @param $alwaysquote
 * @param bool $arraybrackets
 * @return mixed|string
 */
function qa_db_argument_to_mysql($argument, $alwaysquote, $arraybrackets = false)
{
	if (is_array($argument)) {
		$parts = array();

		foreach ($argument as $subargument)
			$parts[] = qa_db_argument_to_mysql($subargument, $alwaysquote, true);

		if ($arraybrackets)
			$result = '(' . implode(',', $parts) . ')';
		else
			$result = implode(',', $parts);
Scott committed
273

Scott committed
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
	} elseif (isset($argument)) {
		if ($alwaysquote || !is_numeric($argument))
			$result = "'" . qa_db_escape_string($argument) . "'";
		else
			$result = qa_db_escape_string($argument);
	} else
		$result = 'NULL';

	return $result;
}


/**
 * Return the full name (with prefix) of database table $rawname, usually if it used after a ^ symbol.
 * @param $rawname
 * @return string
 */
function qa_db_add_table_prefix($rawname)
{
	if (qa_to_override(__FUNCTION__)) { $args=func_get_args(); return qa_call_override(__FUNCTION__, $args); }

	$prefix = QA_MYSQL_TABLE_PREFIX;

	if (defined('QA_MYSQL_USERS_PREFIX')) {
		switch (strtolower($rawname)) {
			case 'users':
			case 'userlogins':
			case 'userprofile':
			case 'userfields':
			case 'messages':
			case 'cookies':
			case 'blobs':
			case 'cache':
			case 'userlogins_ibfk_1': // also special cases for constraint names
			case 'userprofile_ibfk_1':
				$prefix = QA_MYSQL_USERS_PREFIX;
				break;
Scott committed
311 312 313
		}
	}

Scott committed
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343
	return $prefix . $rawname;
}


/**
 * Callback function to add table prefixes, as used in qa_db_apply_sub().
 * @param $matches
 * @return string
 */
function qa_db_prefix_callback($matches)
{
	return qa_db_add_table_prefix($matches[1]);
}


/**
 * Substitute ^, $ and # symbols in $query. ^ symbols are replaced with the table prefix set in qa-config.php.
 * $ and # symbols are replaced in order by the corresponding element in $arguments (if the element is an array,
 * it is converted recursively into comma-separated list). Each element in $arguments is escaped.
 * $ is replaced by the argument in quotes (even if it's a number), # only adds quotes if the argument is non-numeric.
 * It's important to use $ when matching a textual column since MySQL won't use indexes to compare text against numbers.
 * @param $query
 * @param $arguments
 * @return mixed
 */
function qa_db_apply_sub($query, $arguments)
{
	$query = preg_replace_callback('/\^([A-Za-z_0-9]+)/', 'qa_db_prefix_callback', $query);

	if (!is_array($arguments))
Scott committed
344
		return $query;
345

Scott committed
346 347
	$countargs = count($arguments);
	$offset = 0;
Scott committed
348

Scott committed
349 350 351
	for ($argument = 0; $argument < $countargs; $argument++) {
		$stringpos = strpos($query, '$', $offset);
		$numberpos = strpos($query, '#', $offset);
Scott committed
352

Scott committed
353 354 355 356 357 358 359
		if ($stringpos === false || ($numberpos !== false && $numberpos < $stringpos)) {
			$alwaysquote = false;
			$position = $numberpos;
		} else {
			$alwaysquote = true;
			$position = $stringpos;
		}
Scott committed
360

Scott committed
361 362
		if (!is_numeric($position))
			qa_fatal_error('Insufficient parameters in query: ' . $query);
Scott committed
363

Scott committed
364 365 366
		$value = qa_db_argument_to_mysql($arguments[$argument], $alwaysquote);
		$query = substr_replace($query, $value, $position, 1);
		$offset = $position + strlen($value); // allows inserting strings which contain #/$ character
Scott committed
367 368
	}

Scott committed
369 370 371 372 373 374
	return $query;
}


/**
 * Run $query after substituting ^, # and $ symbols, and return the result resource (or call fail handler).
375
 * @param string $query
Scott committed
376 377 378 379 380 381
 * @return mixed
 */
function qa_db_query_sub($query) // arguments for substitution retrieved using func_get_args()
{
	$funcargs = func_get_args();

382 383 384 385 386 387 388 389 390 391 392 393 394
	return qa_db_query_sub_params($query, array_slice($funcargs, 1));
}

/**
 * Run $query after substituting ^, # and $ symbols, and return the result resource (or call fail handler).
 * Query parameters are passed as an array.
 * @param string $query
 * @param array $params
 * @return mixed
 */
function qa_db_query_sub_params($query, $params)
{
	return qa_db_query_raw(qa_db_apply_sub($query, $params));
Scott committed
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462
}


/**
 * Return the number of rows in $result. (Simple wrapper for mysqli_result::num_rows.)
 * @param $result
 * @return int
 */
function qa_db_num_rows($result)
{
	if ($result instanceof mysqli_result)
		return $result->num_rows;

	return 0;
}


/**
 * Return the value of the auto-increment column for the last inserted row.
 */
function qa_db_last_insert_id()
{
	$db = qa_db_connection();
	return $db->insert_id;
}


/**
 * Return the number of rows affected by the last query.
 */
function qa_db_affected_rows()
{
	$db = qa_db_connection();
	return $db->affected_rows;
}


/**
 * For the previous INSERT ... ON DUPLICATE KEY UPDATE query, return whether an insert operation took place.
 */
function qa_db_insert_on_duplicate_inserted()
{
	return (qa_db_affected_rows() == 1);
}


/**
 * Return a random integer (as a string) for use in a BIGINT column.
 * Actual limit is 18,446,744,073,709,551,615 - we aim for 18,446,743,999,999,999,999.
 */
function qa_db_random_bigint()
{
	return sprintf('%d%06d%06d', mt_rand(1, 18446743), mt_rand(0, 999999), mt_rand(0, 999999));
}


/**
 * Return an array of the names of all tables in the Q2A database, converted to lower case.
 * No longer used by Q2A and shouldn't be needed.
 */
function qa_db_list_tables_lc()
{
	return array_map('strtolower', qa_db_list_tables());
}


/**
 * Return an array of the names of all tables in the Q2A database.
463 464 465
 *
 * @param bool $onlyTablesWithPrefix Determine if the result should only include tables with the
 * QA_MYSQL_TABLE_PREFIX or if it should include all tables in the database.
466
 * @return array
Scott committed
467
 */
468
function qa_db_list_tables($onlyTablesWithPrefix = false)
Scott committed
469
{
470 471 472
	$query = 'SHOW TABLES';

	if ($onlyTablesWithPrefix) {
473
		$col = 'Tables_in_' . QA_FINAL_MYSQL_DATABASE;
474 475 476 477
		$query .= ' WHERE `' . $col . '` LIKE "' . str_replace('_', '\\_', QA_MYSQL_TABLE_PREFIX) . '%"';
		if (defined('QA_MYSQL_USERS_PREFIX')) {
			$query .= ' OR `' . $col . '` LIKE "' . str_replace('_', '\\_', QA_MYSQL_USERS_PREFIX) . '%"';
		}
478 479 480
	}

	return qa_db_read_all_values(qa_db_query_raw($query));
Scott committed
481
}
Scott committed
482 483 484


/*
485
	The selectspec array can contain the elements below. See db/selects.php for lots of examples.
Scott committed
486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519

	By default, qa_db_single_select() and qa_db_multi_select() return the data for each selectspec as a numbered
	array of arrays, one per row. The array for each row has column names in the keys, and data in the values.
	But this can be changed using the 'arraykey', 'arrayvalue' and 'single' in the selectspec.

	Note that even if you specify ORDER BY in 'source', the final results may not be ordered. This is because
	the SELECT could be done within a UNION that (annoyingly) doesn't maintain order. Use 'sortasc' or 'sortdesc'
	to fix this. You can however rely on the combination of ORDER BY and LIMIT retrieving the appropriate records.


	'columns' => Array of names of columns to be retrieved (required)

		If a value in the columns array has an integer key, it is retrieved AS itself (in a SQL sense).
		If a value in the columns array has a non-integer key, it is retrieved AS that key.
		Values in the columns array can include table specifiers before the period.

	'source' => Any SQL after FROM, including table names, JOINs, GROUP BY, ORDER BY, WHERE, etc... (required)

	'arguments' => Substitutions in order for $s and #s in the query, applied in qa_db_apply_sub() above (required)

	'arraykey' => Name of column to use for keys of the outer-level returned array, instead of numbers by default

	'arrayvalue' => Name of column to use for values of outer-level returned array, instead of arrays by default

	'single' => If true, return the array for a single row and don't embed it within an outer-level array

	'sortasc' => Sort the output ascending by this column

	'sortdesc' => Sort the output descending by this column


	Why does qa_db_multi_select() combine usually unrelated SELECT statements into a single query?

	Because if the database and web servers are on different computers, there will be latency.
520
	This way we ensure that every read pageview on the site requires as few DB queries as possible, so
Scott committed
521 522 523 524
	that we pay for this latency only one time.

	For writes we worry less, since the user is more likely to be expecting a delay.

525
	If QA_OPTIMIZE_DISTANT_DB is set to false in qa-config.php, we assume zero latency and go back to
Scott committed
526 527 528 529
	simple queries, since this will allow both MySQL and PHP to provide quicker results.
*/


Scott committed
530 531 532 533 534 535 536 537 538
/**
 * Return the data specified by a single $selectspec - see long comment above.
 * @param $selectspec
 * @return array|mixed
 */
function qa_db_single_select($selectspec)
{
	// check for cached results
	if (isset($selectspec['caching'])) {
Scott committed
539
		$cacheDriver = Q2A_Storage_CacheFactory::getCacheDriver();
540
		$cacheKey = 'query:' . $selectspec['caching']['key'];
Scott committed
541

Scott committed
542 543
		if ($cacheDriver->isEnabled()) {
			$queryData = $cacheDriver->get($cacheKey);
Scott committed
544 545
			if ($queryData !== null)
				return $queryData;
Scott committed
546
		}
Scott committed
547
	}
Scott committed
548

Scott committed
549
	$query = 'SELECT ';
Scott committed
550

Scott committed
551 552
	foreach ($selectspec['columns'] as $columnas => $columnfrom)
		$query .= $columnfrom . (is_int($columnas) ? '' : (' AS ' . $columnas)) . ', ';
Scott committed
553

Scott committed
554 555
	$results = qa_db_read_all_assoc(qa_db_query_raw(qa_db_apply_sub(
			substr($query, 0, -2) . (strlen(@$selectspec['source']) ? (' FROM ' . $selectspec['source']) : ''),
Scott committed
556
			@$selectspec['arguments'])
Scott committed
557
	), @$selectspec['arraykey']); // arrayvalue is applied in qa_db_post_select()
Scott committed
558

Scott committed
559
	qa_db_post_select($results, $selectspec); // post-processing
Scott committed
560

Scott committed
561 562
	// save cached results
	if (isset($selectspec['caching'])) {
Scott committed
563 564
		if ($cacheDriver->isEnabled()) {
			$cacheDriver->set($cacheKey, $results, $selectspec['caching']['ttl']);
Scott committed
565
		}
Scott committed
566 567
	}

Scott committed
568 569
	return $results;
}
Scott committed
570 571


Scott committed
572 573 574 575 576 577 578 579 580 581
/**
 * Return the data specified by each element of $selectspecs, where the keys of the
 * returned array match the keys of the supplied $selectspecs array. See long comment above.
 * @param array $selectspecs
 * @return array
 */
function qa_db_multi_select($selectspecs)
{
	if (!count($selectspecs))
		return array();
Scott committed
582

Scott committed
583
	// Perform simple queries if the database is local or there are only 0 or 1 selectspecs
Scott committed
584

Scott committed
585 586
	if (!QA_OPTIMIZE_DISTANT_DB || (count($selectspecs) <= 1)) {
		$outresults = array();
Scott committed
587

Scott committed
588 589
		foreach ($selectspecs as $selectkey => $selectspec)
			$outresults[$selectkey] = qa_db_single_select($selectspec);
Scott committed
590

Scott committed
591 592
		return $outresults;
	}
Scott committed
593

Scott committed
594
	// Otherwise, parse columns for each spec to deal with columns without an 'AS' specification
Scott committed
595

Scott committed
596 597 598
	foreach ($selectspecs as $selectkey => $selectspec) {
		$selectspecs[$selectkey]['outcolumns'] = array();
		$selectspecs[$selectkey]['autocolumn'] = array();
Scott committed
599

Scott committed
600 601 602 603 604
		foreach ($selectspec['columns'] as $columnas => $columnfrom) {
			if (is_int($columnas)) {
				$periodpos = strpos($columnfrom, '.');
				$columnas = is_numeric($periodpos) ? substr($columnfrom, $periodpos + 1) : $columnfrom;
				$selectspecs[$selectkey]['autocolumn'][$columnas] = true;
Scott committed
605 606
			}

Scott committed
607 608
			if (isset($selectspecs[$selectkey]['outcolumns'][$columnas]))
				qa_fatal_error('Duplicate column name in qa_db_multi_select()');
Scott committed
609

Scott committed
610
			$selectspecs[$selectkey]['outcolumns'][$columnas] = $columnfrom;
Scott committed
611 612
		}

Scott committed
613 614 615
		if (isset($selectspec['arraykey']))
			if (!isset($selectspecs[$selectkey]['outcolumns'][$selectspec['arraykey']]))
				qa_fatal_error('Used arraykey not in columns in qa_db_multi_select()');
Scott committed
616

Scott committed
617 618 619 620
		if (isset($selectspec['arrayvalue']))
			if (!isset($selectspecs[$selectkey]['outcolumns'][$selectspec['arrayvalue']]))
				qa_fatal_error('Used arrayvalue not in columns in qa_db_multi_select()');
	}
Scott committed
621

Scott committed
622
	// Work out the full list of columns used
Scott committed
623

Scott committed
624 625 626
	$outcolumns = array();
	foreach ($selectspecs as $selectspec)
		$outcolumns = array_unique(array_merge($outcolumns, array_keys($selectspec['outcolumns'])));
Scott committed
627

Scott committed
628
	// Build the query based on this full list
Scott committed
629

Scott committed
630 631 632
	$query = '';
	foreach ($selectspecs as $selectkey => $selectspec) {
		$subquery = "(SELECT '" . qa_db_escape_string($selectkey) . "'" . (empty($query) ? ' AS selectkey' : '');
Scott committed
633

Scott committed
634 635
		foreach ($outcolumns as $columnas) {
			$subquery .= ', ' . (isset($selectspec['outcolumns'][$columnas]) ? $selectspec['outcolumns'][$columnas] : 'NULL');
Scott committed
636

Scott committed
637 638
			if (empty($query) && !isset($selectspec['autocolumn'][$columnas]))
				$subquery .= ' AS ' . $columnas;
Scott committed
639 640
		}

Scott committed
641 642
		if (strlen(@$selectspec['source']))
			$subquery .= ' FROM ' . $selectspec['source'];
Scott committed
643

Scott committed
644
		$subquery .= ')';
Scott committed
645

Scott committed
646 647
		if (strlen($query))
			$query .= ' UNION ALL ';
Scott committed
648

Scott committed
649
		$query .= qa_db_apply_sub($subquery, @$selectspec['arguments']);
Scott committed
650 651
	}

Scott committed
652
	// Perform query and extract results
Scott committed
653

Scott committed
654
	$rawresults = qa_db_read_all_assoc(qa_db_query_raw($query));
Scott committed
655

Scott committed
656 657 658
	$outresults = array();
	foreach ($selectspecs as $selectkey => $selectspec)
		$outresults[$selectkey] = array();
Scott committed
659

Scott committed
660 661 662
	foreach ($rawresults as $rawresult) {
		$selectkey = $rawresult['selectkey'];
		$selectspec = $selectspecs[$selectkey];
Scott committed
663

Scott committed
664 665 666
		$keepresult = array();
		foreach ($selectspec['outcolumns'] as $columnas => $columnfrom)
			$keepresult[$columnas] = $rawresult[$columnas];
Scott committed
667

Scott committed
668 669 670 671
		if (isset($selectspec['arraykey']))
			$outresults[$selectkey][$keepresult[$selectspec['arraykey']]] = $keepresult;
		else
			$outresults[$selectkey][] = $keepresult;
Scott committed
672 673
	}

Scott committed
674
	// Post-processing to apply various stuff include sorting request, since we can't rely on ORDER BY due to UNION
Scott committed
675

Scott committed
676 677
	foreach ($selectspecs as $selectkey => $selectspec)
		qa_db_post_select($outresults[$selectkey], $selectspec);
Scott committed
678

Scott committed
679
	// Return results
Scott committed
680

Scott committed
681 682
	return $outresults;
}
Scott committed
683 684


Scott committed
685 686 687 688 689 690 691 692 693
/**
 * Post-process $outresult according to $selectspec, applying 'sortasc', 'sortdesc', 'arrayvalue' and 'single'.
 * @param array $outresult
 * @param array $selectspec
 */
function qa_db_post_select(&$outresult, $selectspec)
{
	// PHP's sorting algorithm is not 'stable', so we use '_order_' element to keep stability.
	// By contrast, MySQL's ORDER BY does seem to give the results in a reliable order.
Scott committed
694

Scott committed
695 696
	if (isset($selectspec['sortasc'])) {
		require_once QA_INCLUDE_DIR . 'util/sort.php';
Scott committed
697

Scott committed
698 699 700
		$index = 0;
		foreach ($outresult as $key => $value)
			$outresult[$key]['_order_'] = $index++;
Scott committed
701

Scott committed
702
		qa_sort_by($outresult, $selectspec['sortasc'], '_order_');
Scott committed
703

Scott committed
704 705
	} elseif (isset($selectspec['sortdesc'])) {
		require_once QA_INCLUDE_DIR . 'util/sort.php';
Scott committed
706

Scott committed
707 708
		if (isset($selectspec['sortdesc_2']))
			qa_sort_by($outresult, $selectspec['sortdesc'], $selectspec['sortdesc_2']);
Scott committed
709

Scott committed
710 711 712 713
		else {
			$index = count($outresult);
			foreach ($outresult as $key => $value)
				$outresult[$key]['_order_'] = $index--;
Scott committed
714

Scott committed
715 716
			qa_sort_by($outresult, $selectspec['sortdesc'], '_order_');
		}
Scott committed
717

Scott committed
718
		$outresult = array_reverse($outresult, true);
Scott committed
719 720
	}

Scott committed
721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748
	if (isset($selectspec['arrayvalue']))
		foreach ($outresult as $key => $value)
			$outresult[$key] = $value[$selectspec['arrayvalue']];

	if (@$selectspec['single'])
		$outresult = count($outresult) ? reset($outresult) : null;
}


/**
 * Return the full results from the $result resource as an array. The key of each element in the returned array
 * is from column $key if specified, otherwise it's integer. The value of each element in the returned array
 * is from column $value if specified, otherwise it's a named array of all columns, given an array of arrays.
 * @param $result
 * @param string $key
 * @param mixed $value
 * @return array
 */
function qa_db_read_all_assoc($result, $key = null, $value = null)
{
	if (!($result instanceof mysqli_result))
		qa_fatal_error('Reading all assoc from invalid result');

	$assocs = array();

	while ($assoc = $result->fetch_assoc()) {
		if (isset($key))
			$assocs[$assoc[$key]] = isset($value) ? $assoc[$value] : $assoc;
Scott committed
749
		else
Scott committed
750
			$assocs[] = isset($value) ? $assoc[$value] : $assoc;
Scott committed
751 752
	}

Scott committed
753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838
	return $assocs;
}


/**
 * Return the first row from the $result resource as an array of [column name] => [column value].
 * If there's no first row, throw a fatal error unless $allowempty is true.
 * @param $result
 * @param bool $allowempty
 * @return array|null
 */
function qa_db_read_one_assoc($result, $allowempty = false)
{
	if (!($result instanceof mysqli_result))
		qa_fatal_error('Reading one assoc from invalid result');

	$assoc = $result->fetch_assoc();

	if (is_array($assoc))
		return $assoc;

	if ($allowempty)
		return null;
	else
		qa_fatal_error('Reading one assoc from empty results');
}


/**
 * Return a numbered array containing the first (and presumably only) column from the $result resource.
 * @param $result
 * @return array
 */
function qa_db_read_all_values($result)
{
	if (!($result instanceof mysqli_result))
		qa_fatal_error('Reading column from invalid result');

	$output = array();

	while ($row = $result->fetch_row())
		$output[] = $row[0];

	return $output;
}


/**
 * Return the first column of the first row (and presumably only cell) from the $result resource.
 * If there's no first row, throw a fatal error unless $allowempty is true.
 * @param $result
 * @param bool $allowempty
 * @return mixed|null
 */
function qa_db_read_one_value($result, $allowempty = false)
{
	if (!($result instanceof mysqli_result))
		qa_fatal_error('Reading one value from invalid result');

	$row = $result->fetch_row();

	if (is_array($row))
		return $row[0];

	if ($allowempty)
		return null;
	else
		qa_fatal_error('Reading one value from empty results');
}


/**
 * Suspend the updating of counts (of many different types) in the database, to save time when making a lot of changes
 * if $suspend is true, otherwise reinstate it. A counter is kept to allow multiple calls.
 * @param bool $suspend
 */
function qa_suspend_update_counts($suspend = true)
{
	global $qa_update_counts_suspended;

	$qa_update_counts_suspended += ($suspend ? 1 : -1);
}


/**
 * Returns whether counts should currently be updated (i.e. if count updating has not been suspended).
Scott committed
839
 * @return bool
Scott committed
840 841 842 843 844 845 846
 */
function qa_should_update_counts()
{
	global $qa_update_counts_suspended;

	return ($qa_update_counts_suspended <= 0);
}