$val) { if (strpos($val, '_TRANS') !== false) { $TABLE_LANG_FIELDS_CACHE[$only_table][$key] = $val; } } if (function_exists('persistent_cache_set')) { persistent_cache_set('TABLE_LANG_FIELDS_CACHE', $TABLE_LANG_FIELDS_CACHE); } return; } $msn_running = (is_on_multi_site_network()) && (get_forum_type() == 'cns') && (isset($GLOBALS['FORUM_DB'])); if (multi_lang_content() || $full) { // We need to know about any kind of translated fields in these cases $like = db_string_equal_to('m_type', 'SHORT_TRANS__COMCODE') . ' OR ' . db_string_equal_to('m_type', 'LONG_TRANS__COMCODE') . ' OR ' . db_string_equal_to('m_type', 'SHORT_TRANS') . ' OR ' . db_string_equal_to('m_type', 'LONG_TRANS') . ' OR ' . db_string_equal_to('m_type', '?SHORT_TRANS__COMCODE') . ' OR ' . db_string_equal_to('m_type', '?LONG_TRANS__COMCODE') . ' OR ' . db_string_equal_to('m_type', '?SHORT_TRANS') . ' OR ' . db_string_equal_to('m_type', '?LONG_TRANS'); } else { // In this case we only really need to know about Comcode fields $like = db_string_equal_to('m_type', 'SHORT_TRANS__COMCODE') . ' OR ' . db_string_equal_to('m_type', 'LONG_TRANS__COMCODE') . ' OR ' . db_string_equal_to('m_type', '?SHORT_TRANS__COMCODE') . ' OR ' . db_string_equal_to('m_type', '?LONG_TRANS__COMCODE'); } $sql = 'SELECT m_name,m_table,m_type FROM ' . get_table_prefix() . 'db_meta WHERE (' . $like . ')'; if ($only_table !== null) { $sql .= ' AND ' . db_string_equal_to('m_table', $only_table); } if (($msn_running) && ($only_table !== null) && (substr($only_table, 0, 2) === 'f_')) { $_table_lang_fields = []; // Optimisation, as it'll get overwritten anyway } else { $_table_lang_fields = $GLOBALS['SITE_DB']->query($sql, null, 0, true); // Suppress errors in case table does not exist yet } if ($_table_lang_fields !== null) { // Load in our data foreach ($_table_lang_fields as $lang_field) { if (!isset($TABLE_LANG_FIELDS_CACHE[$lang_field['m_table']])) { $TABLE_LANG_FIELDS_CACHE[$lang_field['m_table']] = []; } $TABLE_LANG_FIELDS_CACHE[$lang_field['m_table']][$lang_field['m_name']] = $lang_field['m_type']; } // Get correct forum DB metadata from central site if (($msn_running) && (($only_table === null) || (substr($only_table, 0, 2) === 'f_'))) { if ($only_table !== null) { unset($TABLE_LANG_FIELDS_CACHE[$only_table]); } unset($TABLE_LANG_FIELDS_CACHE['f_member_custom_fields']); // This may vary between sites in undefined ways $sql .= ' AND m_table LIKE \'' . db_encode_like('f_%') . '\''; $_table_lang_fields_forum = $GLOBALS['FORUM_DB']->query($sql, null, 0, true); if ($_table_lang_fields_forum !== null) { // Load in our data foreach ($_table_lang_fields_forum as $lang_field) { if (!isset($TABLE_LANG_FIELDS_CACHE[$lang_field['m_table']])) { $TABLE_LANG_FIELDS_CACHE[$lang_field['m_table']] = []; } $TABLE_LANG_FIELDS_CACHE[$lang_field['m_table']][$lang_field['m_name']] = $lang_field['m_type']; } } } } if (function_exists('persistent_cache_set')) { persistent_cache_set('TABLE_LANG_FIELDS_CACHE', $TABLE_LANG_FIELDS_CACHE); } } /** * Find lang fields to load within a query. Usually used when JOINs are involved in a query and hence the software cannot automatically determine what the fields will be. * * @param string $table Table name * @param ?string $alias Table alias (null: none) * @return array Map of fields */ function find_lang_fields(string $table, ?string $alias = null) : array { global $TABLE_LANG_FIELDS_CACHE; $lang_fields = isset($TABLE_LANG_FIELDS_CACHE[$table]) ? $TABLE_LANG_FIELDS_CACHE[$table] : []; if ($alias !== null) { foreach ($lang_fields as $lang_field => $lang_field_type) { unset($lang_fields[$lang_field]); $lang_fields[$alias . '.' . $lang_field] = $lang_field_type; } } return $lang_fields; } /** * Get the ID of the first row in an auto-increment table (used whenever we need to reference the first). * * @return integer First ID used */ function db_get_first_id() : int { return $GLOBALS['DB_DRIVER']->get_first_id(); } /** * Encode an SQL statement fragment for a conditional to see if two strings are equal. * * @param ID_TEXT $attribute The attribute * @param string $compare The comparison * @return string The SQL */ function db_string_equal_to(string $attribute, string $compare) : string { return $GLOBALS['DB_DRIVER']->string_equal_to($attribute, $compare); } /** * Encode an SQL statement fragment for a conditional to see if two strings are not equal. * * @param ID_TEXT $attribute The attribute * @param string $compare The comparison * @return string The SQL */ function db_string_not_equal_to(string $attribute, string $compare) : string { return $GLOBALS['DB_DRIVER']->string_not_equal_to($attribute, $compare); } /** * Encode a LIKE string comparison fragment for the database system. The pattern is a mixture of characters and _ and % wildcard symbols. * Regular string escaping is also applied so that you can put the output directly between quotes. * * @param string $pattern The pattern * @return string The encoded pattern */ function db_encode_like(string $pattern) : string { $ret = $GLOBALS['DB_DRIVER']->encode_like($pattern); if (($GLOBALS['DEV_MODE']) || (!function_exists('has_solemnly_declared')) || (!has_solemnly_declared(I_UNDERSTAND_SQL_INJECTION))) { require_code('database_security_filter'); $GLOBALS['DB_ESCAPE_STRING_LIST'][$ret] = true; $GLOBALS['DB_ESCAPE_STRING_LIST'][trim($ret, ' %')] = true; } return $ret; } /** * Encode a WHERE query part for performing a comparison on a BINARY type field. * * @param ID_TEXT $column The column name being compared * @param ID_TEXT $operator The operation to be performed * @set < > = != <= >= * @param string $value The value to compare, in binary string format * @return string The encoded WHERE part */ function db_encode_binary_compare(string $column, string $operator, string $value) : string { return $GLOBALS['DB_DRIVER']->encode_binary_compare($column, $operator, $value); } /** * Escape a string so it may be inserted into a query. If SQL statements are being built up and passed using db_query then it is essential that this is used for security reasons. Otherwise, the abstraction layer deals with the situation. * * @param string $string The string * @return string The escaped string */ function db_escape_string(string $string) : string { if (function_exists('has_solemnly_declared')) { if (($GLOBALS['DEV_MODE']) || (!has_solemnly_declared(I_UNDERSTAND_SQL_INJECTION))) { require_code('database_security_filter'); $GLOBALS['DB_ESCAPE_STRING_LIST'][trim($GLOBALS['DB_DRIVER']->escape_string($string), ' %')] = true; } } return $GLOBALS['DB_DRIVER']->escape_string($string); } /** * Call a database function that may be different on different database drivers. * We are using MySQL syntax as a de facto standard. SQL does not standardise this stuff well. * Basic arithmetic and inequality operators are assumed supported without needing a function. * * @param string $function Function name * @set CONCAT REPLACE SUBSTR LENGTH RAND COALESCE LEAST GREATEST MOD GROUP_CONCAT * @param array $args List of string arguments, assumed already quoted/escaped correctly for the particular database * @return string SQL fragment */ function db_function(string $function, array $args = []) : string { return $GLOBALS['DB_DRIVER']->db_function($function, $args); } /** * Extract certain fields, including any Tempcode details for them, from a DB table row array. * * @param array $row DB table row * @param array $fields List of fields to copy through * @param array $remap Remapping of fields, if we need to do some kind of substitution as well (usually this will be because the row came from a join and we had to rename fields) * @return array Map of fields */ function db_map_restrict(array $row, array $fields, array $remap = []) : array { $out = []; foreach ($fields as $field) { $out[$field] = $row[(array_key_exists($field, $remap) && array_key_exists($remap[$field], $row)) ? $remap[$field] : $field]; if (isset($row[$field . '__text_parsed'])) { $out[$field . '__text_parsed'] = $row[$field . '__text_parsed']; } if (array_key_exists($field . '__source_user', $row)) { $out[$field . '__source_user'] = $row[$field . '__source_user']; } } return $out; } /** * Create an SQL cast. * Note if using this in a SELECT clause you should use 'AS', as you can't predict what the CAST is going to set the field name as. * * @param string $field The field identifier * @param string $type The type wanted * @set CHAR INT FLOAT * @return string The database type */ function db_cast(string $field, string $type) : string { return $GLOBALS['DB_DRIVER']->cast($field, $type); } /** * Find whether we are on a multi-site-network. * We will check to see that the specification for the forum database and site database differ. * Also see: get_db_for, is_forum_db. * * @return boolean Whether we are */ function is_on_multi_site_network() : bool { static $cache = null; if ($cache !== null) { return $cache; } if (get_forum_type() == 'none') { $cache = false; return false; } $cache = ( (get_db_site_host() != get_db_forums_host()) || (get_db_site() != get_db_forums()) || (get_db_site_user() != get_db_forums_user()) || (isset($GLOBALS['FORUM_DRIVER'])) && ($GLOBALS['FORUM_DRIVER']->get_drivered_table_prefix() != get_table_prefix()) ); return $cache; } /** * Find the correct database connection for a particular table. i.e. site connection or forum connection. * This only works with site/Conversr tables, not third-party forums. * If modifying this function, search for other cases in the code for 'f_welcome_emails', as similar logic is used elsewhere. * Also see: is_forum_db, is_on_multi_site_network. * * @param ID_TEXT $table Database table * @param boolean $force_site_db Whether to force use of the site connection * @return object Database connection */ function get_db_for(string $table, bool $force_site_db = false) : object { $table = preg_replace('# .*$#', '', $table); // Strip alias $use_forum_db = ( (substr($table, 0, 2) == 'f_') && ($table != 'f_welcome_emails') && (!$force_site_db) && (get_forum_type() == 'cns') && (isset($GLOBALS['FORUM_DB'])) ); $db = $GLOBALS[$use_forum_db ? 'FORUM_DB' : 'SITE_DB']; return $db; } /** * Get the type of database installed, such as MySQL, or Oracle. * * @return string The database type */ function get_db_type() : string { global $SITE_INFO; if (isset($SITE_INFO['db_type'])) { $ret = $SITE_INFO['db_type']; } else { $ret = 'mysqli'; } if ($ret === 'mysql') { // LEGACY: mysql extension was removed in PHP 7; force mysqli because our minimum is PHP 7.2. $ret = 'mysqli'; } return $ret; } /** * Find whether the software was installed to use persistent database connections or not. * * @return boolean Whether to use persistent database connections */ function get_use_persistent_database() : bool { global $SITE_INFO; if (isset($SITE_INFO['use_persistent'])) { // LEGACY return !empty($SITE_INFO['use_persistent']); } return !empty($SITE_INFO['use_persistent_database']); } /** * Get the table prefixes used for all software tables, commonly used when you are installing the software in the same database as your forums. * The default table prefix is 'cms_'. Note that anything that might write to an arbitrary db, must ask that db for its table prefix (if it needs it of course... the db abstracts away most needs for it). * * @return string The table prefix */ function get_table_prefix() : string { global $SITE_INFO; if (!isset($SITE_INFO['table_prefix'])) { return 'cms' . strval(cms_version()) . '_'; } return $SITE_INFO['table_prefix']; } /** * Get the host of the database ('localhost', for example). * * @return string The database host */ function get_db_site_host() : string { global $SITE_INFO; return (!empty($SITE_INFO['db_site_host'])) ? $SITE_INFO['db_site_host'] : 'localhost'; } /** * Get the name of the database. * * @return string The database site */ function get_db_site() : string { global $SITE_INFO; if (empty($SITE_INFO['db_site'])) { return basename(get_file_base()); } return $SITE_INFO['db_site'] . (($GLOBALS['CURRENT_SHARE_USER'] === null) ? '' : ('_' . $GLOBALS['CURRENT_SHARE_USER'])); } /** * Get the database username. * * @return string The database username */ function get_db_site_user() : string { global $SITE_INFO; if ($GLOBALS['CURRENT_SHARE_USER'] !== null) { return substr(md5($SITE_INFO['db_site_user'] . '_' . $GLOBALS['CURRENT_SHARE_USER']), 0, 16); } return (!empty($SITE_INFO['db_site_user'])) ? $SITE_INFO['db_site_user'] : 'root'; } /** * Get the database password. * * @return string The database password */ function get_db_site_password() : string { global $SITE_INFO; return array_key_exists('db_site_password', $SITE_INFO) ? $SITE_INFO['db_site_password'] : ''; } /** * Get the host of the forum database ('localhost', for example). * * @return string The database host */ function get_db_forums_host() : string { global $SITE_INFO; return (!empty($SITE_INFO['db_forums_host'])) ? $SITE_INFO['db_forums_host'] : (!empty($SITE_INFO['db_site_host']) ? $SITE_INFO['db_site_host'] : 'localhost'); } /** * Get the name of the forum database. * * @return string The forum database site */ function get_db_forums() : string { global $SITE_INFO; if (empty($SITE_INFO['db_forums'])) { return get_db_site(); } return $SITE_INFO['db_forums'] . (($GLOBALS['CURRENT_SHARE_USER'] === null) ? '' : ('_' . $GLOBALS['CURRENT_SHARE_USER'])); } /** * Get the forum database username. * * @return string The forum database username */ function get_db_forums_user() : string { global $SITE_INFO; if (empty($SITE_INFO['db_forums_user'])) { return get_db_site_user(); } if ($GLOBALS['CURRENT_SHARE_USER'] !== null) { return substr(md5($SITE_INFO['db_forums_user'] . '_' . $GLOBALS['CURRENT_SHARE_USER']), 0, 16); } return $SITE_INFO['db_forums_user']; } /** * Get the forum database password. * * @return string The forum database password */ function get_db_forums_password() : string { global $SITE_INFO; if (!array_key_exists('db_forums_password', $SITE_INFO)) { return get_db_site_password(); } return $SITE_INFO['db_forums_password']; } /** * Find out if we are using the InnoDB storage engine. * * @return boolean Whether we are using InnoDB */ function db_is_innodb() : bool { if (strpos(get_db_type(), 'mysql') === false) { return false; } // Used by the installer since the values table does not exist yet global $USE_INNODB; if ($USE_INNODB) { return true; } if (function_exists('get_value') && (get_value('innodb') == '1')) { return true; } return false; } /** * Base class for database drivers. * * @package core_database_drivers */ abstract class DatabaseDriver { protected $echoed_failed_queries = 0; /** * Get the default user for making db connections (used by the installer as a default). * * @return string The default user for db connections */ abstract public function default_user() : string; /** * Get the default password for making db connections (used by the installer as a default). * * @return string The default password for db connections */ abstract public function default_password() : string; /** * Get a database connection. This function shouldn't be used by you, as a connection to the database is established automatically. * * @param boolean $persistent Whether to create a persistent connection * @param string $db_name The database name * @param string $db_host The database host (the server) * @param string $db_user The database connection username * @param string $db_password The database connection password * @param boolean $fail_ok Whether to on error echo an error and return with a null, rather than giving a critical error * @return ?mixed A database connection (null: failed) */ abstract public function get_connection(bool $persistent, string $db_name, string $db_host, string $db_user, string $db_password, bool $fail_ok = false); /** * Adjust an SQL query to apply offset/limit restriction. * * @param string $query The complete SQL query * @param ?integer $max The maximum number of rows to affect (null: no limit) * @param integer $start The start row to affect */ abstract public function apply_sql_limit_clause(string &$query, ?int $max = null, int $start = 0); /** * This function is a very basic query executor. It shouldn't usually be used by you, as there are abstracted versions available. * * @param string $query The complete SQL query * @param mixed $connection The DB connection * @param ?integer $max The maximum number of rows to affect (null: no limit) * @param integer $start The start row to affect * @param boolean $fail_ok Whether to output an error on failure * @param boolean $get_insert_id Whether to get the autoincrement ID created for an insert query * @param boolean $save_as_volatile Whether we are saving as a 'volatile' file extension * @return ?mixed The results (null: no results), or the insert ID */ abstract public function query(string $query, $connection, ?int $max = null, int $start = 0, bool $fail_ok = false, bool $get_insert_id = false, bool $save_as_volatile = false); /** * Insert a update a row (depending on whether a row with the key exists already). * * @param string $table The table name * @param array $map The INSERT/UPDATE map, minus anything in the key * @param array $key_map The WHERE map representing the key [will all be ANDed together] - must be the true key, otherwise rows will be duplicated * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) * @param boolean $save_as_volatile Whether we are saving as a 'volatile' file extension (used in the XML DB driver, to mark things as being non-syndicated to Git) * @return ?string SQL query (null: not supported) */ public function query_insert_or_replace(string $table, array $map, array $key_map, bool $fail_ok = false, bool $save_as_volatile = false) : ?string { return null; } /** * Get a map of software field types, to actual database types. * * @param boolean $for_alter Whether this is for adding a table field * @return array The map */ abstract public function get_type_remap(bool $for_alter = false) : array; /** * Get the implicit field default for a software field type. * * @param string $type Field type * @return mixed The default */ public function get_implicit_field_default(string $type) { $default = mixed(); if (strpos($type, '?') !== false) { return null; } // We need to create a sensible default when none was provided, if the field cannot be null switch (str_replace('*', '', $type)) { case 'AUTO': $default = null; break; case 'AUTO_LINK': case 'INTEGER': case 'UINTEGER': case 'SHORT_INTEGER': case 'BINARY': case 'MEMBER': case 'GROUP': $default = 0; break; case 'REAL': $default = 0.0; break; case 'TIME': $default = time(); break; case 'LONG_TRANS': case 'SHORT_TRANS': case 'LONG_TRANS__COMCODE': case 'SHORT_TRANS__COMCODE': case 'SHORT_TEXT': case 'TEXT': // Do not explicitly use; mainly used for MySQL compatibility with third-party forum drivers (LONG_TEXT does not support default values; see https://bugs.mysql.com/bug.php?id=21532). Use LONG_TEXT instead; the software will switch to TEXT when necessary. case 'LONG_TEXT': case 'ID_TEXT': case 'MINIID_TEXT': case 'IP': case 'LANGUAGE_NAME': case 'URLPATH': case 'TOKEN': case 'SERIAL': case 'BGUID': $default = ''; break; } return $default; } /** * Get SQL for creating a new table. * * @param ID_TEXT $table_name The table name * @param array $fields A map of field names to software field types (with *#? encodings) * @param mixed $connection The DB connection to make on * @param ID_TEXT $raw_table_name The table name with no table prefix * @param boolean $save_bytes Whether to use lower-byte table storage, with trade-offs of not being able to support all unicode characters; use this if key length is an issue * @return array List of SQL queries to run */ abstract public function create_table__sql(string $table_name, array $fields, $connection, string $raw_table_name, bool $save_bytes = false) : array; /** * Get SQL for renaming a table. * * @param ID_TEXT $old Old name * @param ID_TEXT $new New name * @return string SQL query to run */ abstract public function rename_table__sql(string $old, string $new) : string; /** * Get SQL for adding a field to an existing table. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $name The field name * @param ID_TEXT $type The field type * @param ?mixed $default The default value; for a translatable field should still be a string value (null: null default) * @return string SQL query to run */ public function add_table_field__sql(string $table_name, string $name, string $type, $default) : string { // This is standardised by ANSI-SQL. // However, there is a quirk with MySQL to consider (has_default_for_text_fields), // and forum driver install code needs the functionality to easily generate this raw SQL without touching the meta DB, // so it's easier just to define a somewhat-heavyweight function here in driver. // Plus we need a function to generate the raw SQL in other places too. $type_remap = $this->get_type_remap(true); $_type = $type; if (strpos($type, '_TRANS') !== false) { // Some remapping is needed based on multi-lang-content setting if (multi_lang_content()) { if (is_string($default)) { $default = 0; // The caller function is responsible for updating this by creating translate references for each field value } } else { $_type = 'LONG_TEXT'; // In the DB layer, it must now save as such } } if ($_type[0] == '?') { $perhaps_null = 'NULL'; } else { $perhaps_null = 'NOT NULL'; if ($default === null) { // Cannot allow a null default on a non-null field fatal_exit('Tried to request null value on a type that does not allow null'); } } $__type = str_replace(['*', '?'], ['', ''], $_type); $sql_type = $type_remap[$__type] . ' ' . $perhaps_null; if ((($__type != 'LONG_TEXT') && ($__type != 'SERIAL')) || ($this->has_default_for_text_fields())) { // We have to provide a default value for any non-NULL field so the database knows what to put in here if ($default === null) { $extra = ' DEFAULT NULL'; } else { $extra = ' DEFAULT ' . (is_string($default) ? ('\'' . db_escape_string($default) . '\'') : strval($default)); } } else { // The exception is where !has_default_for_text_fields (MySQL), where this is not allowed for TEXT fields and MySQL instead has some weird internal equivalent to an empty string that isn't actually saved onto disk $extra = ''; } $query = 'ALTER TABLE ' . $table_name . ' ADD ' . $name . ' ' . $sql_type . $extra; return $query; } /** * Get SQL for changing the type of a DB field in a table. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $name The field name * @param ID_TEXT $db_type The new field type * @param boolean $may_be_null If the field may be null * @param ?boolean $is_autoincrement Whether it is an autoincrement field (null: could not set it, returned by reference) * @param ID_TEXT $new_name The new field name * @return array List of SQL queries to run */ abstract public function alter_table_field__sql(string $table_name, string $name, string $db_type, bool $may_be_null, ?bool &$is_autoincrement, string $new_name) : array; /** * Get SQL for deleting the specified field from the specified table. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $name The field name * @return string SQL query to run */ public function alter_delete_table_field__sql(string $table_name, string $name) : string { // While this is not in the ANSI SQL standard, all databases support it return 'ALTER TABLE ' . $table_name . ' DROP COLUMN ' . $name; } /** * Get SQL for creating a table index. * * @param ID_TEXT $table_name The name of the table to create the index on * @param ID_TEXT $index_name The index name (not really important at all) * @param string $_fields Part of the SQL query: a comma-separated list of fields to use on the index * @param mixed $connection_read The DB connection, may be used for running checks * @param ID_TEXT $raw_table_name The table name with no table prefix * @param string $unique_key_fields The name of the unique key field for the table * @param string $table_prefix The table prefix * @return array List of SQL queries to run */ abstract public function create_index__sql(string $table_name, string $index_name, string $_fields, $connection_read, string $raw_table_name, string $unique_key_fields, string $table_prefix) : array; /** * Get SQL for deleting a table index. * * @param ID_TEXT $table_name The name of the table the index is on * @param ID_TEXT $index_name The index name * @return ?string SQL query to run (null: not supported) */ abstract public function drop_index__sql(string $table_name, string $index_name) : ?string; /** * Get SQL for creating a foreign key. * * @param ID_TEXT $from_table The table on which we are creating a foreign key * @param ID_TEXT $from_field The table's field on which we are creating a foreign key * @param ID_TEXT $to_table The table which is being referenced * @param ID_TEXT $to_field The table's field which is being referenced * @return ?string The SQL (null: foreign keys not supported) */ public function create_foreign_key__sql(string $from_table, string $from_field, string $to_table, string $to_field) : ?string { // Not supported by default; only MySQL uses this. return null; } /** * Get SQL for deleting a foreign key. * * @param ID_TEXT $from_table The table on which we are deleting a foreign key * @param ID_TEXT $from_field The table's field on which we are deleting a foreign key * @return ?string The SQL (null: foreign keys not supported) */ public function delete_foreign_key__sql(string $from_table, string $from_field) : ?string { // Not supported by default; only MySQL uses this. return null; } /** * Get SQL for changing the primary key of a table. * * @param string $table_prefix The table prefix * @param ID_TEXT $table_name The name of the table to create the index on * @param array $new_key A list of fields to put in the new key * @return array List of SQL queries to run */ abstract public function change_primary_key__sql(string $table_prefix, string $table_name, array $new_key) : array; /** * Get the number of rows in a table, with approximation support for performance (if necessary on the particular database backend). * * @param string $table The table name * @param mixed $connection The DB connection * @return ?integer The count (null: do it normally) */ public function get_table_count_approx(string $table, $connection) : ?int { return null; } /** * Escape a string so it may be inserted into a query. If SQL statements are being built up and passed using db_query then it is essential that this is used for security reasons. Otherwise, the abstraction layer deals with the situation. * * @param string $string The string * @return string The escaped string */ abstract public function escape_string(string $string) : string; /** * Close the database connections. We don't really need to close them (will close at exit), just disassociate so we can refresh them. */ abstract public function close_connections(); /** * Get SQL for deleting a table. * When running this SQL you must suppress errors. * * @param mixed $table The table name(s) * @return array List of SQL queries to run */ public function drop_table_if_exists__sql($table) : array { if (!is_array($table)) { $table = [$table]; } $ret = []; if ($this->has_drop_table_if_exists()) { foreach ($table as $t) { $ret[] = 'DROP TABLE IF EXISTS ' . $t; } } else { foreach ($table as $t) { $ret[] = 'DROP TABLE ' . $t; } } return $ret; } /** * Find whether drop table "if exists" is present. * * @return boolean Whether it is */ public function has_drop_table_if_exists() : bool { return false; } /** * Find whether table truncation support is present. * * @return boolean Whether it is */ public function has_truncate_table() : bool { return true; } /** * Find whether full-text-search is present. * * @param mixed $connection The DB connection * @return boolean Whether it is */ public function has_full_text($connection) : bool { return false; } /** * Find whether full-text-boolean-search is present. * * @return boolean Whether it is */ public function has_full_text_boolean() : bool { return false; } /** * Find whether full-text-search only works on tables with a single key. * * @return boolean Whether it does */ public function full_text_needs_single_key() : bool { return false; } /** * Assemble part of a WHERE clause for doing full-text search. * * @param string $content Our match string (assumes "?" has been stripped already) * @return string Part of a WHERE clause for doing full-text search */ public function full_text_assemble(string $content) : string { warn_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('13f37954ea7556c69fefccbed546b4bc'))); return ''; // Not implemented } /** * Get minimum search length. * This is broadly MySQL-specific. For other databases we will usually return 4, although there may truly not be a limit on it. * * @param mixed $connection The DB connection * @return integer Search length */ public function get_minimum_search_length($connection) : int { return 4; } /** * Find the maximum number of indexes supported. * * @return ?integer Maximum number of indexes (null: no limit or inconsequentially-large limit) */ public function get_max_indexes() : ?int { return 64; } /** * Find whether batch inserts are supported. * * @return boolean Whether they are */ public function has_batch_inserts() : bool { return true; } /** * Find whether expression ordering can happen using ALIASes from the SELECT clause. * * @return boolean Whether it is */ public function has_expression_ordering_by_alias() : bool { return false; } /** * Find whether update queries may have joins. * * @return boolean Whether it is */ public function has_update_joins() : bool { return false; } /** * Find whether auto-increment IDs are sequential. * * @return boolean Whether it does */ public function has_sequential_auto_increment() : bool { return true; } /** * Find whether auto-increment IDs may be used alongside other IDs. * * @return boolean Whether it does */ public function has_compound_auto_increment_support() : bool { return true; } /** * Find whether text fields can/should be given default values when added as a new column to an existing table. * * @return boolean Whether they do */ public function has_default_for_text_fields() : bool { return true; } /** * This function is internal to the database system, allowing SQL statements to be build up appropriately. Some databases require IS NULL to be used to check for blank strings. * * @return boolean Whether a blank string IS NULL */ public function empty_is_null() : bool { return false; } /** * Determine whether the database is a flat file database, and thus not have a meaningful connect username and password. * * @return boolean Whether the database is a flat file database */ public function is_flat_file_simple() : bool { return false; } /** * Get the ID of the first row in an auto-increment table (used whenever we need to reference the first). * * @return integer First ID used */ public function get_first_id() : int { return 1; } /** * Get the character used to surround fields to protect from keyword status. * We generally only use it when renaming fields (renaming them on upgrade so that we don't get a conflict with a keyword). * * @param boolean $end Whether to get end character * @return string Character (blank: has none defined) */ public function get_delimited_identifier(bool $end = false) : string { return '"'; } /** * Create an SQL cast. * * @param string $field The field identifier * @param string $type The type wanted * @set CHAR INT FLOAT * @return string The database type */ public function cast(string $field, string $type) : string { switch ($type) { case 'CHAR': case 'INT': case 'FLOAT': $_type = $type; break; default: fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('0ff97b4287de58c7b66188697bf1b527'))); } return 'CAST(' . $field . ' AS ' . $_type . ')'; } /** * Get a strict mode set query. Takes into account configuration also. * * @param boolean $setting Whether it is on (may be overridden be configuration) * @return ?string The query (null: none) */ public function strict_mode_query(bool $setting) : ?string { return null; } /** * Find if a database query may run, showing errors if it cannot. * * @param string $query The complete SQL query * @param mixed $connection The DB connection * @param boolean $get_insert_id Whether to get the autoincrement ID created for an insert query * @return boolean Whether it can */ public function query_may_run(string $query, $connection, bool $get_insert_id) : bool { return true; } /** * Set a time limit on future queries. * Not all database drivers support this. * * @param integer $seconds The time limit in seconds * @param mixed $connection The DB connection */ public function set_query_time_limit(int $seconds, $connection) { } /** * Encode an SQL statement fragment for a conditional to see if two strings are equal. * * @param ID_TEXT $attribute The attribute * @param string $compare The comparison * @return string The SQL */ public function string_equal_to(string $attribute, string $compare) : string { return $attribute . "='" . db_escape_string($compare) . "'"; } /** * Encode an SQL statement fragment for a conditional to see if two strings are not equal. * * @param ID_TEXT $attribute The attribute * @param string $compare The comparison * @return string The SQL */ public function string_not_equal_to(string $attribute, string $compare) : string { return $attribute . "<>'" . db_escape_string($compare) . "'"; } /** * Encode a LIKE string comparison fragment for the database system. The pattern is a mixture of characters and _ and % wildcard symbols. * Regular string escaping is also applied so that you can put the output directly between quotes. * * @param string $pattern The pattern * @return string The encoded pattern */ public function encode_like(string $pattern) : string { return $this->escape_string($pattern); } /** * Encode a WHERE query part for performing a comparison on a BINARY type field. * * @param ID_TEXT $column The column name being compared * @param ID_TEXT $operator The operation to be performed * @set < > = <> <= >= * @param string $value The value to compare, in binary string format * @return string The encoded WHERE part */ public function encode_binary_compare(string $column, string $operator, string $value) : string { return $column . $operator . '\'' . db_escape_string($value) . '\''; } /** * Exit with an error message. If the user doesn't have permissions to view queries it shows a generic message. * * @param mixed $message Message to show, provided in plain-text format or HTML Tempcode * @exits */ public function failed_query_exit($message) { $this->substitute_query_message($message); fatal_exit($message, false); } /** * Attach a non-fatal error message. If the user doesn't have permissions to view queries it shows a generic message. * * @param mixed $message Message to show, provided in plain-text format or HTML Tempcode */ public function failed_query_message($message) { $this->substitute_query_message($message); attach_message($message, 'warn', false, true); } /** * Echo out an error message (and also log it). If the user doesn't have permissions to view queries it shows a generic message. * Only use this in unusual situations, like upgrading or importing, where throwing out rough messages rather than using the normal framework is the best choice. * * @param string $message Message to show */ public function failed_query_echo(string $message) { // Log the original error so it can later be referenced if (php_function_allowed('error_log')) { @error_log(brand_name() . ' database: ERROR ' . $message); } if (!running_script('upgrader')) { $this->substitute_query_message($message); } echo '
' . $message . "
\n"; // Bomb out anyway if we have a bunch of failed queries; usually indicates something seriously wrong. $this->echoed_failed_queries++; if ($this->echoed_failed_queries >= 25) { $this->failed_query_exit(htmlentities('A safety cut-out was triggered due to a high number of failed database queries.')); } } /** * Substitute an alternative error message if the user doesn't have permissions to view queries it shows a generic message. * Additionally, log the original error. * * @param mixed $message Original message, provided in plain-text format or HTML Tempcode */ protected function substitute_query_message(&$message) { require_code('urls'); $text_eval = is_object($message) ? $message->evaluate() : $message; $php_error_label = $text_eval . ' @ ' . get_self_url_easy(true); if ((function_exists('syslog')) && (GOOGLE_APPENGINE)) { syslog(LOG_ERR, $php_error_label); } if (php_function_allowed('error_log')) { @error_log(brand_name() . ' database: ERROR ' . $php_error_label, 0); } $restricted = false; if (!$GLOBALS['DEV_MODE'] && empty($GLOBALS['IS_ACTUALLY_ADMIN']) && (!running_script('install')) && (!running_script('upgrader'))) { if (function_exists('get_member') && !function_exists('has_privilege')) { require_code('permissions'); // Due to $MICRO_BOOTUP, or some error during bootup } if (!function_exists('get_member') || !function_exists('has_privilege') || !has_privilege(get_member(), 'see_query_errors')) { $restricted = true; } } if ($restricted) { if (function_exists('do_lang')) { $message = do_lang('DATABASE_ERROR'); } else { $message = 'A database error has occurred. The error has been logged so that staff may see what happened.'; } } } /** * Call a database function that may be different on different database drivers. * We are using MySQL syntax as a de facto standard. SQL does not standardise this stuff well. * Basic arithmetic and inequality operators are assumed supported without needing a function. * * Note that AVG may return an integer or float, depending on whether the DB engine auto-converts round numbers to integers. MySQL seems to. * * Note that we are hard-coding for different database drivers in here, as it is easier to maintain the code and reference possibilities in one place. * Database drivers can still override this whole method if they need to. * * Remember to add a test to the database_misc.php test. * * @param string $function Function name * @set IFF CONCAT UPPER LOWER REPLACE SUBSTR INSTR LENGTH RAND COALESCE LEAST GREATEST MOD ABS MD5 REVERSE GROUP_CONCAT X_ORDER_BY_BOOLEAN * @param array $args List of string arguments, assumed already quoted/escaped correctly for the particular database * @return ?string SQL fragment (null: not supported) */ public function db_function(string $function, array $args = []) : ?string { $args = @array_map('strval', $args); switch ($function) { // REMEMBER: Add tests to database_misc.php for anything new you add case 'IFF': /* CASE construct IF func IIF func IF construct mysql Yes Yes No IF...THEN...ELSEIF...ELSE...END IF postgresql Yes No No IF...THEN...ELSIF...ELSE...END IF oracle Yes No Yes IF...THEN...ELSIF...ELSE...END IF db2 Yes No No IF...THEN...ELSEIF...ELSE...END IF SQL server Yes No Yes IF...ELSE... Anything supporting CASE supports both simple and complex forms, as both are standardised. So CASE does not require db_function. For IF checks, we will standardise on IIF as it is the most common. */ switch (get_db_type()) { case 'mysql': case 'mysqli': case 'mysql_pdo': return 'IF(' . implode(',', $args) . ')'; case 'postgresql': return 'CASE WHEN ' . $args[0] . ' THEN ' . $args[1] . ' ELSE ' . $args[2] . ' END'; } break; case 'UPPER': case 'LOWER': if (count($args) != 1) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('b21e947e52fe56d2ae6eca30422395be'))); } switch (get_db_type()) { // Supported on all } break; case 'REPLACE': if (count($args) != 3) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('80eecdf43f3b51adbbf6c94484ee0420'))); } switch (get_db_type()) { // Supported on all http://troels.arvin.dk/db/rdbms/#functions-REPLACE } break; case 'SUBSTR': if (count($args) != 2 && count($args) != 3) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('450cab95859a5e6ea423e2b72b758d8d'))); } switch (get_db_type()) { case 'sqlserver': case 'sqlserver_odbc': if (!isset($args[2])) { $args[1] = db_function('LENGTH', [$args[0]]); // If parameter is more than remaining characters, does not go past end of string but still works } $function = 'SUBSTRING'; // http://troels.arvin.dk/db/rdbms/#functions-REPLACE break; } break; case 'INSTR': if (count($args) != 2) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('5a6e247d261451009206d153dee7bc78'))); } switch (get_db_type()) { case 'postgres': return 'POSITION(' . $args[1] . ' IN ' . $args[0] . ')'; case 'sqlserver': case 'sqlserver_odbc': return 'CHARINDEX(' . $args[1] . ',' . $args[0] . ')'; } break; case 'LENGTH': if (count($args) != 1) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('7ab1ab76179d5c60a0a7802378d71274'))); } switch (get_db_type()) { case 'mysql': case 'mysqli': case 'mysql_pdo': $function = 'CHAR_LENGTH'; break; case 'sqlserver': case 'sqlserver_odbc': $function = 'LEN'; break; } break; case 'RAND': if (!empty($args)) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('c5a7b0170f7e5671af8becd4e52154a5'))); } switch (get_db_type()) { case 'postgresql': $function = 'RANDOM'; break; } break; case 'COALESCE': if (count($args) != 2) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('36fbbd9d3757531495b9e1f5761497bb'))); } switch (get_db_type()) { default: $all_null = true; foreach ($args as $arg) { if ($arg != 'NULL') { $all_null = false; break; } } if ($all_null) { return 'NULL'; } } break; case 'LEAST': switch (get_db_type()) { case 'sqlserver': case 'sqlserver_odbc': $ret = '(SELECT MIN(X) FROM ('; foreach ($args as $i => $arg) { if ($i != 0) { $ret .= ' UNION ALL '; } $ret .= 'SELECT ' . $arg . ' AS X'; } $ret .= ') ' . 'x' . md5(uniqid('', true)) . ')'; return $ret; } break; case 'GREATEST': switch (get_db_type()) { case 'sqlserver': case 'sqlserver_odbc': $ret = '(SELECT MAX(X) FROM ('; foreach ($args as $i => $arg) { if ($i != 0) { $ret .= ' UNION ALL '; } $ret .= 'SELECT ' . $arg . ' AS X'; } $ret .= ') ' . 'x' . md5(uniqid('', true)) . ')'; return $ret; } break; case 'MOD': if (count($args) != 2) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('54c4d9a73bc55a5187015a8905f6e966'))); } switch (get_db_type()) { case 'postgresql': case 'sqlserver': case 'sqlserver_odbc': return $args[0] . ' % ' . $args[1]; } break; case 'ABS': break; case 'MD5': if (count($args) != 1) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('e7ada32f648a5860b04989d32ca095fa'))); } switch (get_db_type()) { case 'oracle': return 'STANDARD_HASH(' . $args[0] . ',\'MD5\')'; case 'postgresql': return 'HASH(' . $args[0] . ',0)'; case 'sqlserver': case 'sqlserver_odbc': return 'HASHBYTES(\'MD5\',' . $args[0] . ')'; } break; // The following may not be fully supported on all database systems... case 'REVERSE': if (count($args) != 1) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('d4b887dc20665c838d69ac3a3db73785'))); } switch (get_db_type()) { case 'db2': return null; } break; case 'GROUP_CONCAT': if (count($args) != 2) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('1165066576cb56d5bb6e6b7f0b0b0f67'))); } switch (get_db_type()) { case 'oracle': return '(SELECT LISTAGG(' . $args[0] . ', \',\') WITHIN GROUP (ORDER BY ' . $args[0] . ') FROM ' . $args[1] . ')'; case 'postgresql': return '(SELECT array_to_string(array_agg(' . $args[0] . '), \',\') FROM ' . $args[1] . ')'; case 'sqlserver': return 'STUFF((SELECT \',\'+' . $args[0] . ' FROM ' . $args[1] . ' FOR XML PATH(\'\')), 1, 1, \'\')'; case 'ibm': // Not fully supported return '(SELECT ' . $args[0] . ' FROM ' . $args[1] . ' fetch first 1 rows only)'; case 'xml': return '(SELECT X_GROUP_CONCAT(' . $args[0] . ') FROM ' . $args[1] . ')'; case 'mysql': case 'mysqli': case 'mysql_pdo': default: return '(SELECT GROUP_CONCAT(' . $args[0] . ') FROM ' . $args[1] . ')'; } break; case 'X_ORDER_BY_BOOLEAN': if (count($args) != 1) { fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('6f341173c31556478fa860b92a6f5cba'))); } switch (get_db_type()) { case 'sqlserver': case 'sqlserver_odbc': return '(CASE WHEN ' . $args[0] . ' THEN 0 ELSE 1 END)'; default: return $args[0]; } break; } if (get_db_type() == 'xml') { $function = 'X_' . $function; } // Default handling return $function . '(' . implode(',', $args) . ')'; } } /** * Database handling. * * @package core */ class DatabaseConnector { public $table_prefix; public $connection_read; public $connection_write; public $connection_unique_identifier; public $text_lookup_original_cache; public $text_lookup_cache; public $table_exists_cache; public $table_exists_real_cache; public $driver; public $dedupe_mode = false; // These tables should use a delayed insert where possible and available protected $delayed_insert_tables = ['stats', 'banner_clicks', 'member_tracking', 'usersonline_track', 'download_logging']; /** * Construct a database driver from connection parameters. * * @param string $db_name The database name * @param string $db_host The database server * @param string $db_user The connection username * @param string $db_password The connection password * @param string $table_prefix The table prefix * @param boolean $fail_ok Whether to on error echo an error and return with a null, rather than giving a critical error * @param ?object $static Static call object (null: use global static call object) */ public function __construct(string $db_name, string $db_host, string $db_user, string $db_password, string $table_prefix, bool $fail_ok = false, ?object $static = null) { $this->text_lookup_original_cache = []; $this->text_lookup_cache = []; $this->table_exists_cache = []; $this->table_exists_real_cache = []; $servers = explode(',', $db_host); if (count($servers) == 1) { $this->connection_write = [get_use_persistent_database(), $db_name, $db_host, $db_user, $db_password, $fail_ok]; $this->connection_read = $this->connection_write; } else { $this->connection_write = [get_use_persistent_database(), $db_name, $servers[0], $db_user, $db_password, $fail_ok]; $min = (count($servers) == 2) ? 0 : 1; $this->connection_read = [get_use_persistent_database(), $db_name, $servers[mt_rand($min, count($servers) - 1)], $db_user, $db_password, $fail_ok]; } $this->table_prefix = $table_prefix; $this->connection_unique_identifier = serialize([$db_name, $db_host, $db_user, $db_password, $table_prefix]); if ($static !== null) { $this->driver = $static; } else { $this->driver = $GLOBALS['DB_DRIVER']; } } /** * Ensure the database connection is connected. */ public function ensure_connected() { if ((is_array($this->connection_read)) && (isset($this->connection_read[4]))) { // Okay, we can't be lazy anymore $this->connection_read = call_user_func_array([$this->driver, 'get_connection'], $this->connection_read); if ($this->connection_read === null) { return; // Failed already } if ((is_array($this->connection_write)) && (isset($this->connection_write[4]))) { // Okay, we can't be lazy anymore $this->connection_write = call_user_func_array([$this->driver, 'get_connection'], $this->connection_write); } if ($this->connection_write === null) { return; // Failed already } if (isset($GLOBALS['SITE_DB']) && $this === $GLOBALS['SITE_DB']) { _general_db_init(); } // We need database connections closed after absolutely everything else, so chain some cms_register_shutdown_function_safe calls. $driver = $this->driver; cms_register_shutdown_function_if_available(function () use ($driver) { cms_register_shutdown_function_if_available(function () use ($driver) { cms_register_shutdown_function_if_available(function () use ($driver) { cms_register_shutdown_function_if_available([$driver, 'close_connections']); }); }); }); } } /** * Get the table prefixes used for all software tables, commonly used when you are installing the software in the same database as your forums. The default table prefix is 'cms_'. * * @return string The table prefix */ public function get_table_prefix() : string { return $this->table_prefix; } /** * Create a SELECT query from some abstract data. * * @param string $table The table to select from * @param ?array $select_map List of field selections (null: all fields) * @param array $where_map Map of conditions to enforce * @param string $end Additional stuff to tack onto the query * @return string SQL query */ protected function _get_where_expand(string $table, ?array $select_map = null, array $where_map = [], string $end = '') : string { global $DEV_MODE; if ($select_map === null) { $select_map = ['*']; } $select = ''; foreach ($select_map as $key) { //if (!is_string($key)) $key = strval($key); Should not happen, but won't cause a problem if does. Don't do this check for performance reasons. if ($select !== '') { $select .= ','; } $select .= $key; } $where = ''; if (!empty($where_map)) { foreach ($where_map as $key => $value) { if ($DEV_MODE) { if (!is_string($key)) { fatal_exit('Parameters to the database API given in the wrong order. Please check the function call.'); } } if ($where !== '') { $where .= ' AND '; } if (is_float($value)) { $where .= $key . '=' . number_format($value, 10, '.', ''); } elseif (is_integer($value)) { $where .= $key . '=' . strval($value); } elseif (($key === 'begin_num') || ($key === 'end_num')) { $where .= $key . '=' . $value; // FUDGE: for all our known large unsigned integers #3046 } else { if ($value === null) { $where .= $key . ' IS NULL'; } else { if (($value === '') && ($this->driver->empty_is_null())) { $value = ' '; } $where .= db_string_equal_to($key, $value); } } } return 'SELECT ' . $select . ' FROM ' . $table . ' WHERE (' . $where . ') ' . $end; } if (substr(ltrim($end), 0, 6) !== 'WHERE ') { $end = 'WHERE 1=1 ' . $end; // We force a WHERE so that code of ours that alters queries can work robustly } return 'SELECT ' . $select . ' FROM ' . $table . ' ' . $end; } /** * Get the specified value from the database. This is the specified value of the first row returned. A fatal error is produced if there is no matching row. * * @param string $table The table name * @param string $selected_value The field to select * @param array $where_map The WHERE map [will all be ANDed together] * @param string $end Something to tack onto the end * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) * @param ?array $lang_fields Extra language fields to join in for cache pre-filling / Tempcode, perhaps via the find_lang_fields function. You only need to send this if you are doing a JOIN and carefully craft your query so table field names won't conflict (null: auto-detect, if not a join) * @return mixed The first value of the first row returned */ public function query_select_value(string $table, string $selected_value, array $where_map = [], string $end = '', bool $fail_ok = false, ?array $lang_fields = null) { if ($GLOBALS['DEV_MODE'] && (strpos($selected_value, 'COUNT(*)') !== false) && (count($where_map) == 0) && ($end == '')) { attach_message('Detected query_select_value use for a simple COUNT query; You should use get_table_count_approx instead for optimisation.', 'notice', false, true); } $values = $this->query_select($table, [$selected_value], $where_map, $end, 1, 0, $fail_ok, $lang_fields); if ($values === null) { return null; // error } if (!array_key_exists(0, $values)) { $this->driver->failed_query_exit(do_lang_tempcode('QUERY_NULL', escape_html($this->_get_where_expand($this->table_prefix . $table, [$selected_value], $where_map, $end)))); // No result found } return $this->_query_select_value($values); } /** * Extract the first of the first of the list of maps. * * @param array $values The list of maps * @return mixed The first value of the first row in the list */ protected function _query_select_value(array $values) { if (!array_key_exists(0, $values)) { return null; // No result found } $first = $values[0]; $v = current($first); // Result found. Maybe a value of 'null' return $v; } /** * Get the specified value from the database, or null if there is no matching row (or if the value itself is null). This is good for detection existence of records, or for use if they might may or may not be present. * * @param string $table The table name * @param string $select The field to select * @param array $where_map The WHERE map [will all be ANDed together] * @param string $end Something to tack onto the end * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) * @param ?array $lang_fields Extra language fields to join in for cache pre-filling / Tempcode, perhaps via the find_lang_fields function. You only need to send this if you are doing a JOIN and carefully craft your query so table field names won't conflict (null: auto-detect, if not a join) * @return ?mixed The first value of the first row returned (null: nothing found, or null value found) */ public function query_select_value_if_there(string $table, string $select, array $where_map = [], string $end = '', bool $fail_ok = false, ?array $lang_fields = null) { if ($GLOBALS['DEV_MODE'] && (strpos($select, 'COUNT(*)') !== false) && (count($where_map) == 0) && ($end == '')) { attach_message('Detected query_select_value_if_there use for a simple COUNT query; You should use get_table_count_approx instead for optimisation.', 'notice', false, true); } $values = $this->query_select($table, [$select], $where_map, $end, 1, 0, $fail_ok, $lang_fields); if ($values === null) { return null; // error } return $this->_query_select_value($values); } /** * This function is a variant of query_select_value_if_there, by the fact that it only accepts a complete (and perfect) SQL query, instead of assembling one itself from the specified parameters. * * @param string $query The complete SQL query * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) * @param boolean $skip_safety_check Whether to skip the query safety check * @param ?array $lang_fields Extra language fields to join in for cache pre-filling / Tempcode, perhaps via the find_lang_fields function. You only need to send this if you are doing a JOIN and carefully craft your query so table field names won't conflict (null: auto-detect, if not a join) * @return ?mixed The first value of the first row returned (null: nothing found, or null value found) */ public function query_value_if_there(string $query, bool $fail_ok = false, bool $skip_safety_check = false, ?array $lang_fields = null) { global $DEV_MODE; if (($DEV_MODE) || (!has_solemnly_declared(I_UNDERSTAND_SQL_INJECTION))) { if (!is_bool($fail_ok)) { fatal_exit('You probably wanted to use query_select_value_if_there'); } if (!$skip_safety_check) { require_code('database_security_filter'); if (is_simple_query($query)) { fatal_exit('It is highly recommended to use query_select/query_update/query_delete helper methods instead of the \'query\' method for this query -- in order to avoid having to write any SQL, ' . $query); } if (!has_escaped_dynamic_sql($query)) { fatal_exit('Dynamic SQL has not been escaped properly in ' . $query); } } } $values = $this->query($query, 1, 0, $fail_ok, $skip_safety_check, $lang_fields); if ($values === null) { return null; // error } return $this->_query_select_value($values); } /** * Get the database rows found matching the specified parameters. Unlike 'query', it doesn't take raw SQL -- it assembles SQL based the parameters requested. * Only use this if you're where condition is a series of AND clauses doing simple property comparisons. * * @param string $table The table name * @param array $select The SELECT map * @param array $where_map The WHERE map [will all be ANDed together] * @param string $end Something to tack onto the end of the SQL query * @param ?integer $max The maximum number of rows to select (null: get all) * @param integer $start The starting row to select * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) * @param ?array $lang_fields Extra language fields to join in for cache pre-filling / Tempcode, perhaps via the find_lang_fields function. You only need to send this if you are doing a JOIN and carefully craft your query so table field names won't conflict (null: auto-detect, if not a join) * @return ?array The results (empty array: empty result set) (null: error) */ public function query_select(string $table, array $select = ['*'], array $where_map = [], string $end = '', ?int $max = null, int $start = 0, bool $fail_ok = false, ?array $lang_fields = null) : ?array { $full_table = $this->table_prefix . $table; $field_prefix = ''; $this->_automatic_lang_fields($table, $full_table, $select, $where_map, $end, $lang_fields); return $this->_query($this->_get_where_expand($full_table, $select, $where_map, $end), $max, $start, $fail_ok, false, $lang_fields, $field_prefix); } /** * Work out $lang_fields from analysing the table, if needed. * * @param string $table The table name * @param string $full_table The table name, with prefix too * @param array $select The SELECT map * @param array $where_map The WHERE map [will all be ANDed together] * @param string $end Something to tack onto the end of the SQL query * @param ?array $lang_fields Extra language fields to join in for cache pre-filling / Tempcode, perhaps via the find_lang_fields function. You only need to send this if you are doing a JOIN and carefully craft your query so table field names won't conflict (null: auto-detect, if not a join) */ protected function _automatic_lang_fields(string &$table, string &$full_table, array &$select, array &$where_map, string &$end, ?array &$lang_fields) { // Optimisation for entirely automatic translate table linkage (only done on non-joins, as this removes a whole lot of potential complexities -- if people are doing joins they go a little further to do this manually anyway; also we make sure we're operating on our site's table prefix so we don't collect meta info for the wrong table set) if ($lang_fields === null) { if ( ($table !== 'translate') && (strpos($table, ' ') === false) && (strpos($end, 'GROUP BY ') === false/*Can only SELECT what is also in GROUP BY*/) && ((isset($GLOBALS['SITE_DB'])) && ($this->connection_unique_identifier === $GLOBALS['SITE_DB']->connection_unique_identifier) || (get_forum_type() === 'cns') && ($this->connection_unique_identifier == $GLOBALS['CNS_DRIVER']->db->connection_unique_identifier)) ) { global $TABLE_LANG_FIELDS_CACHE; $lang_fields_provisional = find_lang_fields($table); $lang_fields = []; if (!empty($lang_fields_provisional)) { $full_table .= ' main'; foreach ($select as $i => $s) { if (!is_string($s)) { $lang_fields_provisional = []; break; // Bad API call, but we'll let it fail naturally } if (preg_match('#^[A-Za-z_\*]+$#', $s) !== 0) { $select[$i] = 'main.' . $s; } } if (is_array($where_map)) { foreach ($where_map as $i => $s) { if (!is_string($i)) { $lang_fields_provisional = []; break; // Bad API call, but we'll let it fail naturally } if (preg_match('#^[A-Za-z_]+$#', $i) !== 0) { unset($where_map[$i]); $where_map['main.' . $i] = $s; } } } if ($end !== '') { $end = cms_preg_replace_safe('#(^|,|\s)([a-z]+)($|,|\s)#', '${1}main.${2}${3}', $end); } $field_prefix = 'main.'; $select_inv = array_flip($select); foreach ($lang_fields_provisional as $lang_field => $field_type) { if ( (isset($select_inv[$field_prefix . '*'])) || (isset($select_inv[$field_prefix . $lang_field])) || (isset($select_inv['t_' . $lang_field . '.text_original'])) || (isset($where_map['t_' . $lang_field . '.text_original'])) || (strpos($end, 't_' . $lang_field . '.text_original') !== false) ) { $lang_fields[$lang_field] = $field_type; } } } } } } /** * Do a named-parameter query, like "SELECT * FROM foo WHERE " . db_string_equal_to('a', '{value}') . ";" with ['value' => 'b']. * Assumes nothing looking like {example} is in the query already. * Also supports {prefix} for encoding the table prefix. * You must still use db_string_equal_to and db_string_not_equal_to in the query where applicable. * Lots of programmers like to do queries like this as it reduces the chance of accidentally forgetting to escape a parameter inserted directly/manually within a longer query. * Usually we use APIs like query_select, which avoids the need for SQL all-together, but this doesn't work for all patterns of query. * * @param string $query The complete parameter-ready SQL query * @param array $parameters The query parameters (a map) * @param ?integer $max The maximum number of rows to affect (null: no limit) * @param integer $start The start row to affect * @param boolean $fail_ok Whether to output an error on failure * @param boolean $skip_safety_check Whether to skip the query safety check * @param ?array $lang_fields Extra language fields to join in for cache pre-filling / Tempcode, perhaps via the find_lang_fields function. You only need to send this if you are doing a JOIN and carefully craft your query so table field names won't conflict (null: auto-detect, if not a join) * @param string $field_prefix All the core fields have a prefix of this on them, so when we fiddle with language lookup we need to use this (only consider this if you're setting $lang_fields) * @return ?mixed The results (null: no result set) (empty array: empty result set) */ public function query_parameterised(string $query, array $parameters, ?int $max = null, int $start = 0, bool $fail_ok = false, bool $skip_safety_check = false, ?array $lang_fields = null, string $field_prefix = '') { $query = $this->_query_parameterised($query, $parameters); return $this->query($query, $max, $start, $fail_ok, $skip_safety_check, $lang_fields, $field_prefix); } /** * Helper for query_parameterised. * * @param string $query The complete parameter-ready SQL query * @param array $parameters The query parameters (a map) * @return string Parameterised query */ public function _query_parameterised(string $query, array $parameters) : string { if (isset($parameters['prefix'])) { warn_exit('prefix is a reserved parameter, you should not set it.'); } $val = mixed(); $len = strlen($query); $current_parameter = null; $query_new = ''; $in_quotes_start = false; for ($i = 0; $i < $len; $i++) { $c = $query[$i]; if ($current_parameter === null) { if ($c == '{') { $current_parameter = ''; $in_quotes_start = (($i > 0) && ($query[$i - 1] == "'")); } else { $query_new .= $c; } } else { if ($c == '}') { if ($current_parameter == 'prefix') { $query_new .= $this->table_prefix; } elseif (array_key_exists($current_parameter, $parameters)) { $in_quotes_end = (($i < $len - 1) && ($query[$i + 1] == "'")); $val = $parameters[$current_parameter]; if (is_integer($val)) { $val = strval($val); if ($in_quotes_start || $in_quotes_end) { $val = db_escape_string($val); } } elseif (is_float($val)) { $val = number_format($val, 10, '.', ''); if ($in_quotes_start || $in_quotes_end) { $val = db_escape_string($val); } } elseif (is_bool($val)) { $val = ($val ? '1' : '0'); if ($in_quotes_start || $in_quotes_end) { $val = db_escape_string($val); } } elseif ($val === null) { $val = 'NULL'; if ($in_quotes_start) { $query_new = substr($query_new, 0, strlen($query_new) - 1); } if ($in_quotes_end) { $i++; } } else { if (!is_string($val)) { $val = strval($val); } $val = db_escape_string($val); if (!$in_quotes_start) { $val = "'" . $val; } if (!$in_quotes_end) { $val .= "'"; } } $query_new .= $val; } else { $query_new .= '{' . $current_parameter . '}'; // No match so leave alone } $current_parameter = null; } else { $current_parameter .= $c; } } } return $query_new; } /** * This function is a raw query executor. * This should rarely ever be used; other functions like query_select are available. Additionally, for complex queries, it is still better to use query_parameterised as it handles escaping. * * @param string $query The complete SQL query * @param ?integer $max The maximum number of rows to affect (null: no limit) * @param integer $start The start row to affect * @param boolean $fail_ok Whether to output an error on failure * @param boolean $skip_safety_check Whether to skip the query safety check * @param ?array $lang_fields Extra language fields to join in for cache pre-filling / Tempcode, perhaps via the find_lang_fields function. You only need to send this if you are doing a JOIN and carefully craft your query so table field names won't conflict (null: none) * @param string $field_prefix All the core fields have a prefix of this on them, so when we fiddle with language lookup we need to use this (only consider this if you're setting $lang_fields) * @return ?mixed The results (null: no result set) (empty array: empty result set) */ public function query(string $query, ?int $max = null, int $start = 0, bool $fail_ok = false, bool $skip_safety_check = false, ?array $lang_fields = null, string $field_prefix = '') { global $DEV_MODE; if ((!$skip_safety_check) && (stripos($query, 'union') !== false) && (strpos(get_db_type(), 'mysql') !== false)) { $_query = cms_preg_replace_safe('#\s#', ' ', cms_strtolower_ascii($query)); $queries = 1;//substr_count($_query, 'insert into ') + substr_count($_query, 'replace into ') + substr_count($_query, 'update ') + substr_count($_query, 'select ') + substr_count($_query, 'delete from '); Not reliable if ((strpos(preg_replace('#\'[^\']*\'#', '\'\'', str_replace('\\\'', '', $_query)), ' union ') !== false) || ($queries > 1)) { log_hack_attack_and_exit('SQL_INJECTION_HACK', $query); warn_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('33c0a1c39c43553cab3844e1460bdfee'))); } if (function_exists('has_solemnly_declared')) { if (($DEV_MODE) || (!has_solemnly_declared(I_UNDERSTAND_SQL_INJECTION))) { require_code('database_security_filter'); if (is_simple_query($query)) { fatal_exit('It is highly recommended to use query_select/query_update/query_delete method instead of the \'query\' method for this query, ' . $query); } if (!has_escaped_dynamic_sql($query)) { fatal_exit('Dynamic SQL has not been escaped properly in ' . $query); } } } } return $this->_query($query, $max, $start, $fail_ok, false, $lang_fields, $field_prefix); } /** * Convert a field name of type SHORT/LONG_TRANS[__COMCODE] into something we may use directly in our SQL. * Assumes the query has separately been informed of the $lang_fields parameter (which is automatic for query_select). * * @param ID_TEXT $field_name Language field name * @return ID_TEXT SQL field name reference */ public function translate_field_ref(string $field_name) : string { if (multi_lang_content()) { return 't_' . $field_name . '.text_original'; } return $field_name; } /** * Create a join into the translate table, with a preference to language-matched translations but fall-back. * * @param ID_TEXT $field Field name of what we are joining against * @param string $join_alias Join alias * @param ?LANGUAGE_NAME $lang Language (null: current language) * @param string $join_type Join type * @return string Join SQL */ public function translate_field_join(string $field, string $join_alias = 't', ?string $lang = null, string $join_type = 'LEFT JOIN') : string { if ($lang === null) { $lang = user_lang(); } $join = ' ' . $join_type . ' ' . $this->table_prefix . 'translate ' . $join_alias . ' ON ' . $join_alias . '.id=' . $field; $translate_order_by = db_function('X_ORDER_BY_BOOLEAN', [db_string_equal_to('language', $lang)]); if ($translate_order_by !== null) { if ($lang != get_site_default_lang()) { $translate_order_by .= ',' . db_function('X_ORDER_BY_BOOLEAN', [db_string_equal_to('language', get_site_default_lang())]); } } $subquery = 'SELECT language FROM ' . $this->table_prefix . 'translate WHERE id=' . $field; if ($translate_order_by !== null) { $subquery .= ' ORDER BY ' . $translate_order_by; } $this->driver->apply_sql_limit_clause($subquery, 1); $join .= ' AND ' . $join_alias . '.language=(' . $subquery . ')'; return $join; } /** * This function is a very basic query executor. It shouldn't usually be used by you, as there are specialised abstracted versions available. * * @param string $query The complete SQL query * @param ?integer $max The maximum number of rows to affect (null: no limit) * @param integer $start The start row to affect * @param boolean $fail_ok Whether to output an error on failure * @param boolean $get_insert_id Whether to get an insert ID * @param ?array $lang_fields Extra language fields to join in for cache pre-filling / Tempcode, perhaps via the find_lang_fields function. You only need to send this if you are doing a JOIN and carefully craft your query so table field names won't conflict (null: none) * @param string $field_prefix All the core fields have a prefix of this on them, so when we fiddle with language lookup we need to use this (only consider this if you're setting $lang_fields) * @param boolean $save_as_volatile Whether we are saving as a 'volatile' file extension (used in the XML DB driver, to mark things as being non-syndicated to Git) * @return ?mixed The results (null: no result set) (empty array: empty result set) */ public function _query(string $query, ?int $max = null, int $start = 0, bool $fail_ok = false, bool $get_insert_id = false, ?array $lang_fields = null, string $field_prefix = '', bool $save_as_volatile = false) { global $QUERY_COUNT, $QUERY_LOG, $QUERY_LIST, $DEV_MODE, $IN_MINIKERNEL_VERSION, $QUERY_FILE_LOG, $UPON_QUERY_HOOKS_CACHE; if ($QUERY_FILE_LOG !== null) { flock($QUERY_FILE_LOG, LOCK_EX); fseek($QUERY_FILE_LOG, 0, SEEK_END); fwrite($QUERY_FILE_LOG, loggable_date() . ' -- ' . $query . ';' . "\n\n"); flock($QUERY_FILE_LOG, LOCK_UN); } if ($DEV_MODE) { if (peek_db_scope_check()) { if ((multi_lang_content() === false) && (strpos($query, $this->table_prefix . 'translate') !== false) && (strpos($query, 'CHECK TABLE') === false) && (strpos($query, 'DROP TABLE') === false) && (strpos($query, 'DROP INDEX') === false) && (strpos($query, 'ALTER TABLE') === false) && (strpos($query, 'CREATE TABLE') === false) && (trim($query) != 'SELECT * FROM cms_translate WHERE 1=1')) { fatal_exit('Assumption of multi-lang-content being on, and it\'s not'); } if ((get_forum_type() == 'cns') && (strpos($query, get_table_prefix() . 'f_') !== false) && (strpos($query, get_table_prefix() . 'f_') < 100) && (strpos($query, 'f_welcome_emails') === false) && (!$this->is_forum_db()) && (is_on_multi_site_network())) { fatal_exit('Using Conversr queries on the wrong driver'); } } } if (peek_query_limiting()) { $QUERY_COUNT++; /* if ($QUERY_COUNT > 10) { cms_ob_end_clean(); } @header('Query: ' . $query . "\n"); */ } if ((!headers_sent()) && (function_exists('fb_wrap')) && (get_param_integer('keep_firephp_queries', 0) === 1)) { fb_wrap('Query: ' . $query); } if (($QUERY_COUNT === DEV_MODE_QUERY_LIMIT) && (get_param_integer('keep_query_limit', null) !== 0) && ($GLOBALS['RELATIVE_PATH'] !== '_tests') && (count($_POST) === 0) && (!$IN_MINIKERNEL_VERSION) && (get_param_string('special_page_type', '') !== 'query')) { cms_profile_start_for('_query:HIGH_VOLUME_ALERT'); push_query_limiting(false); if (php_function_allowed('error_log')) { require_code('urls'); require_code('failure'); @error_log(brand_name() . ' profiling: INFO Over ' . integer_format(DEV_MODE_QUERY_LIMIT) . ' queries @ ' . get_self_url_easy(true) . "\n" . get_text_trace(), 0); } if ($DEV_MODE) { $QUERY_COUNT = 0; fatal_exit(do_lang_tempcode('TOO_MANY_QUERIES')); } cms_profile_end_for('_query:HIGH_VOLUME_ALERT'); } // Optimisation: Load language fields in advance so we don't need to do additional details when calling get_translated_* functions $lang_strings_expecting = []; if (!empty($lang_fields)) { if (multi_lang_content()) { if ((strpos($query, 'text_original') !== false) || (function_exists('user_lang')) && ($start < 200)) { $lang = function_exists('user_lang') ? user_lang() : get_site_default_lang(); // We can we assume this, as we will cache against it -- if subsequently code wants something else it'd be a cache miss which is fine list($from_pos, $query_tail_pos) = $this->find_query_hotspots($query); foreach ($lang_fields as $field => $field_type) { $field_stripped = preg_replace('#.*\.#', '', $field); $join = $this->translate_field_join($field_prefix . $field, 't_' . $field_stripped, $lang); if ($query_tail_pos !== false) { $query = substr($query, 0, $query_tail_pos) . $join . substr($query, $query_tail_pos); $query_tail_pos += strlen($join); } else { $query .= $join; } $before_from = substr($query, 0, $from_pos); if (preg_match('#(COUNT|SUM|AVG|MIN|MAX)\(#', $before_from) === 0) { // If we're returning full result sets (as opposed probably to just joining so we can use translate_field_ref) $original = 't_' . $field_stripped . '.text_original AS t_' . $field_stripped . '__text_original'; $parsed = 't_' . $field_stripped . '.text_parsed AS t_' . $field_stripped . '__text_parsed'; $extra_select_fields = ',' . $original . ',' . $parsed; $query = $before_from . $extra_select_fields . substr($query, $from_pos); if ($query_tail_pos !== false) { $query_tail_pos += strlen($extra_select_fields); } $lang_strings_expecting[] = [$field, 't_' . $field_stripped . '__text_original', 't_' . $field_stripped . '__text_parsed']; } } } } else { foreach ($lang_fields as $field => $field_type) { if (strpos($field_type, '__COMCODE') !== false) { list($from_pos, $query_tail_pos) = $this->find_query_hotspots($query); $before_from = substr($query, 0, $from_pos); if (preg_match('#(COUNT|SUM|AVG|MIN|MAX)\(#', $before_from) === 0) { // If we're returning full result sets (as opposed probably to just joining so we can use translate_field_ref) $source_user = $field . '__source_user'; $parsed = $field . '__text_parsed'; if ($before_from != 'SELECT ' . $field_prefix . '*') { $query = $before_from . ',' . $source_user . ',' . $parsed . substr($query, $from_pos); } } } } } } if ($start < 0) { $start = 0; } if ($max < 0) { $max = 1; } if ($QUERY_LOG) { $before = microtime(true); } $this->ensure_connected(); $sub = substr($query, 0, 6); // NB: We don't get 7, because it's time-consuming to check for space/tab/new-lines after 'SELECT', so we'll make the correct assumption SELECT is not a stem of any other keyword if ($sub === 'SELECT' || $sub === 'select' || $sub === '(SELEC' || $sub === '(selec') { $connection = &$this->connection_read; } else { $connection = &$this->connection_write; } // Special handling for searches, which are slow and specific - we want to recognise if previous active searches were the same and kill them (as this would have been a double form submit) if (($this->dedupe_mode) && (substr(get_db_type(), 0, 5) === 'mysql')) { $query .= '/* ' . get_session_id() . ' */'; // Identify query to session, for accurate de-duping $real_query = $query; if (($max !== null) && ($start != 0)) { $real_query .= ' LIMIT ' . strval($start) . ',' . strval($max); } elseif ($max !== null) { $real_query .= ' LIMIT ' . strval($max); } elseif ($start != 0) { $real_query .= ' LIMIT ' . strval($start) . ',30000000'; } $ret = $this->driver->query('SHOW FULL PROCESSLIST', $connection, null, 0, true); // Suppress errors in case access denied if (is_array($ret)) { foreach ($ret as $process) { if ($process['Info'] === $real_query) { $this->driver->query('KILL ' . strval($process['Id']), $connection, null, 0, true); // Suppress errors in case access denied } } } } // Run hooks, if any exist if ($UPON_QUERY_HOOKS_CACHE === null) { if ((!running_script('restore')) && (function_exists('find_all_hooks')) && (!isset($GLOBALS['DOING_USERS_INIT'])/*can't check for safe mode meaning can't get a full hook list yet*/)) { $UPON_QUERY_HOOKS_CACHE = find_all_hook_obs('systems', 'upon_query', 'Hook_upon_query_'); } } if ($UPON_QUERY_HOOKS_CACHE !== null) { foreach ($UPON_QUERY_HOOKS_CACHE as $ob) { if (($ob !== null) && (method_exists($ob, 'run_pre')) && ($query != '')) { $ob->run_pre($this, $query, $max, $start, $fail_ok, $get_insert_id); } } } // Run/log query $ret = $this->driver->query($query, $connection, $max, $start, $fail_ok, $get_insert_id, false, $save_as_volatile); if ($QUERY_LOG) { $after = microtime(true); $text = ($max !== null) ? ($query . ' (' . strval($start) . '-' . strval($start + $max) . ')') : $query; $out = ['time' => ($after - $before), 'text' => $text, 'rows' => is_array($ret) ? count($ret) : null]; $QUERY_LIST[] = $out; } /* Generally one would use MySQL's own slow query log, which will impact performance less if (microtime_diff($after, $before) > 1.0) { cms_profile_start_for('_query:SLOW_ALERT'); cms_profile_end_for('_query:SLOW_ALERT', $query); } */ // Run hooks, if any exist if ($UPON_QUERY_HOOKS_CACHE !== null) { foreach ($UPON_QUERY_HOOKS_CACHE as $ob) { if (($ob !== null) && (method_exists($ob, 'run_post')) && ($query != '')) { $ob->run_post($this, $query, $max, $start, $fail_ok, $get_insert_id, $ret); } } } if ($ret !== null) { foreach ($lang_strings_expecting as $bits) { list($field, $original, $parsed) = $bits; if ((isset($ret[300])) && (strpos($query, 'theme_images') === false) && (strpos($query, 'group_category_access') === false) && (strpos($query, 'group_privileges') === false) && (strpos($query, 'config') === false)) { cms_profile_start_for('_query:MANY_RESULTS_ALERT'); cms_profile_end_for('_query:MANY_RESULTS_ALERT', $query); } if (multi_lang_content()) { // Copy results to lang cache, but only if not null AND unset to avoid any confusion foreach ($ret as $i => $row) { if (!isset($row[$field])) { continue; // Probably dereferenced to text_original in WHERE, but not selected } $entry = $row[preg_replace('#^.*\.#', '', $field)]; if (($row[$original] !== null) && (count($this->text_lookup_original_cache) <= 1000)) { $this->text_lookup_original_cache[$entry] = $row[$original]; } if (($row[$parsed] !== null) && (count($this->text_lookup_cache) <= 1000)) { $this->text_lookup_cache[$entry] = $row[$parsed]; } unset($ret[$i][$original]); unset($ret[$i][$parsed]); } } } } return $ret; } /** * Find key parts of SQL query so we can inject into it. * * @param string $query The query * @return array A pair: position just before " FROM ", position just before the end of the FROM clause */ protected function find_query_hotspots(string $query) : array { $len = strlen($query); $brackets_open = 0; $from_pos = null; $query_tail_pos = null; for ($i = 0; $i < $len; $i++) { $c = $query[$i]; if ($c == '(') { $brackets_open++; } elseif ($c == ')') { $brackets_open--; } elseif (($brackets_open == 0) && (trim($c) == '')/*i.e. we're at some whitespace*/) { $matches = []; if (preg_match('#\s(FROM|WHERE|GROUP\sBY|ORDER\sBY)#iA', $query, $matches, 0, $i) != 0) { if (($from_pos === null) && ($matches[1] == 'FROM')) { $from_pos = $i; } elseif (($query_tail_pos === null) && ($matches[1] != 'FROM')) { $query_tail_pos = $i; break; } } } } if ($query_tail_pos === null) { $query_tail_pos = $len; @exit($query); } return [$from_pos, $query_tail_pos]; } /** * Find whether this database connector is to the forum database. * If we are not on a multi-site-network then the answer is always 'No', because really we're checking to see if we are the forum database and also not the site database. * Also see: get_db_for, is_on_multi_site_network. * * @return boolean Whether we are */ public function is_forum_db() : bool { if (isset($this->is_forum_db)) { return $this->is_forum_db; } if (!is_on_multi_site_network()) { // Not on a multi-site-network return false; } $ret = ((isset($GLOBALS['FORUM_DB'])) && ($this->connection_write == $GLOBALS['FORUM_DB']->connection_write) && ($this->connection_write != $GLOBALS['SITE_DB']->connection_write)); $this->is_forum_db = $ret; return $ret; } /** * Find whether full-text-search is present. * * @param ?string $table Table to check we have an index on (null: no check) * @param ?string $field Field to check we have an index on (null: no check) * @return boolean Whether it is */ public function has_full_text(?string $table = null, ?string $field = null) : bool { if (($table !== null) && ($field !== null)) { $field = preg_replace('#^.*\.(.*)#', '$1', $field); $indexes = $GLOBALS['SITE_DB']->query_select('db_meta_indices', ['i_fields', 'i_name'], ['i_table' => $table]); $okay = false; foreach ($indexes as $index) { if ($index['i_name'][0] == '#') { $fields = explode(',', $index['i_fields']); if (in_array($field, $fields)) { $okay = true; break; } } } if (!$okay) { return false; } } $this->ensure_connected(); return $this->driver->has_full_text($this->connection_read); } /** * Assemble part of a WHERE clause for doing full-text search. * * @param string $content Our match string (assumes "?" has been stripped already) * @return string Part of a WHERE clause for doing full-text search */ public function full_text_assemble(string $content) : string { $this->ensure_connected(); return $this->driver->full_text_assemble($content); } /** * Turn a list of maps into the bulk-insert format used by query_insert. * * @param array $maps List of maps * @return array The row format for bulk-inserts */ public function bulk_insert_flip(array $maps) : array { $data = []; foreach ($maps as $map) { foreach ($map as $key => $val) { if (!isset($data[$key])) { $data[$key] = []; } $data[$key][] = $val; } } return $data; } /** * Insert a update a row (depending on whether a row with the key exists already). * * @param string $table The table name * @param array $map The INSERT/UPDATE map, minus anything in the key * @param array $key_map The WHERE map representing the key [will all be ANDed together] - must be the true key, otherwise rows will be duplicated * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) * @param boolean $save_as_volatile Whether we are saving as a 'volatile' file extension (used in the XML DB driver, to mark things as being non-syndicated to Git) * @return boolean Whether a smart operation was performed by the DB backend (rather than just delete-then-add) */ public function query_insert_or_replace(string $table, array $map, array $key_map, bool $fail_ok = false, bool $save_as_volatile = false) : bool { $query = $this->driver->query_insert_or_replace($this->table_prefix . $table, $map, $key_map, $fail_ok, $save_as_volatile); if ($query !== null) { $this->_query($query, null, 0, $fail_ok, false, null, '', $save_as_volatile); return true; } // Not supported on other DB backends $GLOBALS['SITE_DB']->query_delete($table, $key_map, '', 1); $GLOBALS['SITE_DB']->query_insert($table, $map + $key_map, false, true); // To stop weird race-like conditions return false; } /** * Insert a row. * * @param string $table The table name * @param array $map The insertion map. The map values may be arrays for a multi-insert, but if so they must all have the same arity. You must not pass an array of maps. * @param boolean $ret Whether to return the auto-insert-id * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) * @param boolean $save_as_volatile Whether we are saving as a 'volatile' file extension (used in the XML DB driver, to mark things as being non-syndicated to Git) * @return ?integer The ID of the new row (null: no ID / batch insert) */ public function query_insert(string $table, array $map, bool $ret = false, bool $fail_ok = false, bool $save_as_volatile = false) : ?int { $keys = ''; $all_values = []; // will usually only have a single entry; for bulk-inserts it will have as many as there are inserts $eis = $this->driver->empty_is_null(); foreach ($map as $key => $value) { if ($keys !== '') { $keys .= ', '; } $keys .= $key; $_value = (!is_array($value)) ? [$value] : $value; $v = mixed(); foreach ($_value as $i => $v) { if (!isset($all_values[$i])) { $all_values[$i] = ''; } $values = $all_values[$i]; if ($values !== '') { $values .= ', '; } if ($value === null) { if (($eis) && ($v === '')) { $values .= '\' \''; } else { $values .= 'NULL'; } } else { if (($eis) && ($v === '')) { $v = ' '; } if (is_integer($v)) { $values .= strval($v); } elseif (is_float($v)) { $values .= number_format($v, 10, '.', ''); } elseif (($key === 'begin_num') || ($key === 'end_num')) { $values .= $v; // FUDGE: for all our known large unsigned integers #3046 } else { $values .= '\'' . $this->driver->escape_string($v) . '\''; } } $all_values[$i] = $values; // essentially appends, as $values was loaded from former $all_values[$i] value } } if (count($all_values) === 1) { // usually $all_values only has length of 1 if ((function_exists('get_value')) && (get_value('enable_delayed_inserts') === '1') && (in_array($table, $this->delayed_insert_tables)) && (substr(get_db_type(), 0, 5) === 'mysql')) { $query = 'INSERT DELAYED INTO ' . $this->table_prefix . $table . ' (' . $keys . ') VALUES (' . $all_values[0] . ')'; // This is a very MySQL-specific optimisation (MyISAM). Other DBs don't do table-level locking! } else { $query = 'INSERT INTO ' . $this->table_prefix . $table . ' (' . $keys . ') VALUES (' . $all_values[0] . ')'; } } elseif (empty($all_values)) { return null; } else { if (!$this->driver->has_batch_inserts()) { foreach ($all_values as $v) { $query = 'INSERT INTO ' . $this->table_prefix . $table . ' (' . $keys . ') VALUES (' . $v . ')'; $this->_query($query, null, 0, $fail_ok, $ret, null, '', $save_as_volatile); } return null; } // So we can do batch inserts... $all_v = ''; foreach ($all_values as $v) { if ($all_v !== '') { $all_v .= ', '; } $all_v .= '(' . $v . ')'; } $query = 'INSERT INTO ' . $this->table_prefix . $table . ' (' . $keys . ') VALUES ' . $all_v; } return $this->_query($query, null, 0, $fail_ok, $ret, null, '', $save_as_volatile); } /** * Update (edit) a row in the database. * * @param string $table The table name * @param array $update_map The UPDATE map * @param array $where_map The WHERE map [will all be ANDed together] * @param string $end Something to tack onto the end of the statement * @param ?integer $max The maximum number of rows to update (null: no limit) * @param integer $start The starting row to update * @param boolean $num_touched Whether to get the number of touched rows. WARNING: Do not use in core code as it does not work on all database drivers * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) * @return ?integer The number of touched records (null: hasn't been asked / error / not supported) */ public function query_update(string $table, array $update_map, array $where_map = [], string $end = '', ?int $max = null, int $start = 0, bool $num_touched = false, bool $fail_ok = false) : ?int { $where = ''; $update = ''; $value = mixed(); foreach ($where_map as $key => $value) { if ($where !== '') { $where .= ' AND '; } if (is_float($value)) { $where .= $key . '=' . number_format($value, 10, '.', ''); } elseif (is_integer($value)) { $where .= $key . '=' . strval($value); } elseif (($key === 'begin_num') || ($key === 'end_num')) { $where .= $key . '=' . $value; // FUDGE: for all our known large unsigned integers #3046 } else { if ($value === null) { $where .= $key . ' IS NULL'; } else { if (($value === '') && ($this->driver->empty_is_null())) { $value = ' '; } $where .= db_string_equal_to($key, $value); } } } foreach ($update_map as $key => $value) { if (($value === STRING_MAGIC_NULL) || ($value === INTEGER_MAGIC_NULL)) { continue; } if ($update !== '') { $update .= ', '; } if ($value === null) { $update .= $key . '=NULL'; } else { if (is_float($value)) { $update .= $key . '=' . number_format($value, 10, '.', ''); } elseif (is_integer($value)) { $update .= $key . '=' . strval($value); } elseif (($key === 'begin_num') || ($key === 'end_num')) { $where .= $key . '=' . $value; // FUDGE: for all our known large unsigned integers #3046 } else { $update .= $key . '=\'' . $this->driver->escape_string($value) . '\''; } } } if ($update === '') { return null; } if ($where === '') { return $this->_query('UPDATE ' . $this->table_prefix . $table . ' SET ' . $update . ' ' . $end, $max, $start, $fail_ok, $num_touched); } return $this->_query('UPDATE ' . $this->table_prefix . $table . ' SET ' . $update . ' WHERE (' . $where . ') ' . $end, $max, $start, $fail_ok, $num_touched); } /** * Deletes rows from the specified table, that match the specified conditions (if any). It may be limited to a row range (it is likely, only a maximum, of 1, will be used, if any kind of range at all). * * @param string $table The table name * @param array $where_map The WHERE map [will all be ANDed together] * @param string $end Something to tack onto the end of the statement * @param ?integer $max The maximum number of rows to delete (null: no limit) * @param integer $start The starting row to delete * @param boolean $fail_ok Whether to allow failure (outputting a message instead of exiting completely) */ public function query_delete(string $table, array $where_map = [], string $end = '', ?int $max = null, int $start = 0, bool $fail_ok = false) { if (empty($where_map)) { if (($end === '') && ($max === null) && ($start == 0) && ($this->driver->has_truncate_table($GLOBALS['SITE_DB']->connection_read))) { $this->_query('TRUNCATE TABLE ' . $this->table_prefix . $table, null, 0, $fail_ok); } else { $this->_query('DELETE FROM ' . $this->table_prefix . $table . ' ' . $end, $max, $start, $fail_ok); } return; } $where = ''; foreach ($where_map as $key => $value) { if ($where !== '') { $where .= ' AND '; } if (is_float($value)) { $where .= $key . '=' . number_format($value, 10, '.', ''); } elseif (is_integer($value)) { $where .= $key . '=' . strval($value); } elseif (($key === 'begin_num') || ($key === 'end_num')) { $where .= $key . '=' . $value; // FUDGE: for all our known large unsigned integers #3046 } else { if ($value === null) { $where .= $key . ' IS NULL'; } else { if (($value === '') && ($this->driver->empty_is_null())) { $where .= $key . ' IS NULL'; // $value = ' '; } else { $where .= db_string_equal_to($key, $value); } } } } $query = 'DELETE FROM ' . $this->table_prefix . $table . ' WHERE (' . $where . ') ' . $end; $this->_query($query, $max, $start, $fail_ok); } /** * Check if a table exists. * * @param ID_TEXT $table_name The table name * @param boolean $really_only Only check direct rather than using meta-table (if possible) (false means check both directly and using meta-table if possible) * @return boolean Whether it exists */ public function table_exists(string $table_name, bool $really_only = false) : bool { if ((isset($this->table_exists_real_cache[$table_name])) && (($really_only) || (isset($this->table_exists_cache[$table_name])))) { return $this->table_exists_real_cache[$table_name] && ($really_only || $this->table_exists_cache[$table_name]); } $this->table_exists_real_cache[$table_name] = false; $this->table_exists_cache[$table_name] = false; if (strpos(get_db_type(), 'mysql') !== false) { // Just works with MySQL (too complex to do for all SQL's http://forums.whirlpool.net.au/forum-replies-archive.cfm/523219.html)... $prefix = $this->table_prefix; static $cached_show_tables = []; if (!array_key_exists($this->connection_unique_identifier, $cached_show_tables)) { $cached_show_tables[$this->connection_unique_identifier] = $this->query('SHOW TABLES LIKE \'' . db_encode_like($this->table_prefix . '%') . '\''); } foreach ($cached_show_tables[$this->connection_unique_identifier] as $row) { foreach ($row as $field) { if (substr($field, 0, strlen($prefix)) == $prefix) { $_table_name = substr($field, strlen($prefix)); $this->table_exists_real_cache[$_table_name] = true; } } } if ($really_only || !$this->table_exists_real_cache[$table_name]) { return $this->table_exists_real_cache[$table_name]; } } static $cached_meta_tables = []; if (!array_key_exists($this->connection_unique_identifier, $cached_meta_tables)) { $cached_meta_tables[$this->connection_unique_identifier] = $this->query_select('db_meta', ['DISTINCT m_table']); } foreach ($cached_meta_tables[$this->connection_unique_identifier] as $row) { $this->table_exists_cache[$row['m_table']] = true; } return $this->table_exists_cache[$table_name]; } /** * Generate a JOIN clause that will only bind to a single row. * * @param string $table Table to join to * @param string $alias Alias for the table * @param string $join_clause The regular join clause * @param string $threshold_field The field we will use to choose which row to match * @param string $threshold_func The SQL function to apply to $threshold_field * @param string $join_type The join type * @set "LEFT JOIN" "JOIN" * @param ?string $prefer_index Index to force (null: none) * @return string JOIN clause */ public function singular_join(string $table, string $alias, string $join_clause, string $threshold_field = 'id', string $threshold_func = 'MIN', string $join_type = 'LEFT JOIN', ?string $prefer_index = null) : string { $_table = $this->table_prefix . $table; $on_clause = $alias . '.' . $threshold_field . '=(SELECT ' . $threshold_func . '(dedupe.' . $threshold_field . ') FROM ' . $_table . ' dedupe WHERE ' . $join_clause . ')'; $ret = ' ' . $join_type . ' ' . $_table . ' ' . $alias; if ($prefer_index !== null) { $ret .= $this->prefer_index($table, $prefer_index); } $ret .= ' ON ' . $on_clause; return $ret; } /** * Create a table with the given name and the given array of field name to type mappings. * If a field type starts '*', then it is part of that field's key. If it starts '?', then it is an optional field. * * @param ID_TEXT $table_name The table name * @param array $fields The fields * @param boolean $skip_size_check Whether to skip the size check for the table (only do this for addon modules that don't need to support anything other than MySQL) * @param boolean $skip_null_check Whether to skip the check for null string fields * @param ?boolean $save_bytes Whether to use lower-byte table storage, with trade-offs of not being able to support all unicode characters; use this if key length is an issue (null: autodetect) * @param boolean $skip_fulltext_key_check Whether to skip checking if keys are appropriate for any auto-created fulltext indices */ public function create_table(string $table_name, array $fields, bool $skip_size_check = false, bool $skip_null_check = false, ?bool $save_bytes = false, bool $skip_fulltext_key_check = false) { require_code('database_helper'); _helper_create_table($this, $table_name, $fields, $skip_size_check, $skip_null_check, $save_bytes, $skip_fulltext_key_check); } /** * Rename the given table. * * @param ID_TEXT $old The old table name * @param ID_TEXT $new The new table name */ public function rename_table(string $old, string $new) { require_code('database_helper'); _helper_rename_table($this, $old, $new); } /** * Drop the given table, or if it doesn't exist, silently return. * * @param mixed $table The table name(s) */ public function drop_table_if_exists($table) { require_code('database_helper'); _helper_drop_table_if_exists($this, $table); } /** * Adds a field to an existing table. * Note: this function cannot add a new AUTO key, use add_auto_key for that. * Note: this function cannot change the keys in the database on its own (use change_primary_key for that), although you should include * if something will be a key. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $name The field name * @param ID_TEXT $type The field type * @param ?mixed $default The default value; for a translatable field should still be a string value (null: null default / default default) */ public function add_table_field(string $table_name, string $name, string $type, $default = null) { require_code('database_helper'); _helper_add_table_field($this, $table_name, $name, $type, $default); } /** * Change the type of a DB field in a table. * Note: this function cannot change the keys in the database on its own (use change_primary_key for that), although you should include * if something will be a key. * Note: this function does not support ascension/descension of translatability, use promote_text_field_to_comcode for that. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $name The field name * @param ID_TEXT $type The new field type * @param ?ID_TEXT $new_name The new field name (null: leave name) * @return boolean Whether we failed to set an auto-increment */ public function alter_table_field(string $table_name, string $name, string $type, ?string $new_name = null) : bool { require_code('database_helper'); return _helper_alter_table_field($this, $table_name, $name, $type, $new_name); } /** * Delete the specified field from the specified table. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $name The field name */ public function delete_table_field(string $table_name, string $name) { require_code('database_helper'); _helper_delete_table_field($this, $table_name, $name); } /** * Change the primary key of a table. * * @param ID_TEXT $table_name The name of the table to create the index on * @param array $new_key A list of fields to put in the new key */ public function change_primary_key(string $table_name, array $new_key) { require_code('database_helper'); _helper_change_primary_key($this, $table_name, $new_key); } /** * Use an *AUTO key for a table that had some other key before. * @param ID_TEXT $table_name Table name * @param ID_TEXT $field_name Field name for new key, must always be 'id' */ public function add_auto_key(string $table_name, string $field_name = 'id') { require_code('database_helper'); _helper_add_auto_key($this, $table_name, $field_name); } /** * If a text field has picked up Comcode support, we will need to run this. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $name The field name * @param ID_TEXT $key The tables key field name * @param integer $level The translation level to use * @set 1 2 3 4 * @param boolean $in_assembly Whether our data is already stored in Tempcode assembly format * @param boolean $long_trans Whether this should be a LONG_TRANS__COMCODE field, not a SHORT_TRANS__COMCODE field */ public function promote_text_field_to_comcode(string $table_name, string $name, string $key = 'id', int $level = 2, bool $in_assembly = false, bool $long_trans = false) { require_code('database_helper'); _helper_promote_text_field_to_comcode($this, $table_name, $name, $key, $level, $in_assembly, $long_trans); } /** * If we've changed what $type is stored as, this function will need to be called to change the typing in the DB. * * @param ID_TEXT $type The field type */ public function refresh_field_definition(string $type) { require_code('database_helper'); _helper_refresh_field_definition($this, $type); } /** * Get extra SQL for marking an index preferred within a query. Output should be appended after table name within a query (basically). * * @param ID_TEXT $table The table name * @param ID_TEXT $index The index name * @param boolean $do_check_first Check the index actually exists first (sometimes we add new indexes in patch releases for performance reasons, but referencing them would cause a fatal error) * @return string SQL to add */ public function prefer_index(string $table, string $index, bool $do_check_first = true) : string { static $cache = []; if (isset($cache[$table][$index])) { return $cache[$table][$index]; } $ret = ''; if (strpos(get_db_type(), 'mysql') !== false) { if ((!$do_check_first) || (get_value('assume_indices_exist') === '1') || ($GLOBALS['SITE_DB']->query_select_value_if_there('db_meta_indices', 'i_fields', ['i_table' => $table, 'i_name' => $index]) !== null)) { $ret = ' FORCE INDEX (' . filter_naughty_harsh($index) . ')'; } } $cache[$table][$index] = $ret; return $ret; } /** * Add an index to a table without disturbing the contents, after the table has been created. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $index_name The index name * @param array $fields The fields * @param ?string $unique_key_fields Comma-separated names of the unique key field for the table (null: lookup) * @param boolean $skip_fulltext_key_check Whether to skip checking if keys are appropriate for any auto-created fulltext indices */ public function create_index(string $table_name, string $index_name, array $fields, ?string $unique_key_fields = null, bool $skip_fulltext_key_check = false) { require_code('database_helper'); _helper_create_index($this, $table_name, $index_name, $fields, $unique_key_fields, $skip_fulltext_key_check); } /** * Delete an index from a table. * * @param ID_TEXT $table_name The table name * @param ID_TEXT $index_name The index name */ public function delete_index_if_exists(string $table_name, string $index_name) { require_code('database_helper'); _helper_delete_index_if_exists($this, $table_name, $index_name); } /** * Create a foreign key constraint. * * @param ID_TEXT $from_table The table on which to create the foreign key * @param ID_TEXT $from_field The field on which to create the foreign key * @param ID_TEXT $to_table The table which is referenced * @param ID_TEXT $to_field The field which is referenced * @param array $special_values List of special values which $from_field can be which do not reference anything in $to_field */ public function create_foreign_key(string $from_table, string $from_field, string $to_table, string $to_field, array $special_values = []) { require_code('database_helper'); _helper_create_foreign_key($this, $from_table, $from_field, $to_table, $to_field, $special_values); } /** * Delete a foreign key constraint. * * @param ID_TEXT $from_table The table from which to delete the foreign key * @param ID_TEXT $from_field The field from which to delete the foreign key */ public function delete_foreign_key_if_exists(string $from_table, string $from_field) { require_code('database_helper'); _helper_delete_foreign_key_if_exists($this, $from_table, $from_field); } /** * Get the number of rows in a table, with approximation support for performance (if necessary on the particular database backend). * * @param string $table The table name * @param array $where WHERE clauses if it will help get a more reliable number when we're not approximating in map form * @param ?string $where_clause WHERE clauses if it will help get a more reliable number when we're not approximating in SQL form (null: none) * @return ?integer The count (null: do it normally) */ public function get_table_count_approx(string $table, array $where = [], ?string $where_clause = null) : ?int { $this->ensure_connected(); $ret = $this->driver->get_table_count_approx($this->table_prefix . $table, $this->connection_read); if ($ret !== null) { return $ret; } return $this->query_select_value($table, 'COUNT(*)', $where, ($where_clause === null) ? ' AND 1=1' : (' AND ' . $where_clause)); } /** * Wait up to 5 iterations for a table to be unlocked (if it is locked). * Only works with MySQL/MyISAM (and irrelevant for other DBs which don't do table-level locking). * * @param ID_TEXT $table The table name * @return boolean Whether the table was still locked after trying 5 times */ public function table_is_locked(string $table) : bool { if (in_array($table, $this->delayed_insert_tables)) { return false; // Actually, we have delayed insert for these so locking is not an issue } // InnoDB tables do not have table-level locking, and non-MySQL databases also do not support it if ((strpos(get_db_type(), 'mysql') === false) || db_is_innodb()) { return false; } static $cache = []; if (isset($cache[$table])) { return $cache[$table]; } $tries = 0; do { if ((substr($table, 0, 2) == 'f_') && ($table != 'f_welcome_emails')) { $db_name = get_db_forums(); } else { $db_name = get_db_site(); } $locks = $this->query('SHOW OPEN TABLES FROM ' . $db_name . ' WHERE `Table`=\'' . db_escape_string($this->table_prefix . $table) . '\' AND In_use>=1', null, 0, true); // Suppress errors in case access denied if ($locks === null) { return false; // MySQL version older than 5.0 (e.g. 4.1.x) } $locked = count($locks) >= 1; $tries++; if ($locked) { if (php_function_allowed('usleep')) { usleep(50000); // 50ms wait } } } while (($locked) && ($tries < 5)); $cache[$table] = $locked; return $locked; } /** * Set a time limit on future queries. * Not all database drivers support this. * * @param integer $seconds The time limit in seconds */ public function set_query_time_limit(int $seconds) { $this->ensure_connected(); $this->driver->set_query_time_limit($seconds, $this->connection_read); } /** * Get a strict mode set query. Takes into account configuration also. * * @param boolean $setting Whether it is on (may be overridden be configuration) * @return ?string The query (null: none) */ public function strict_mode_query(bool $setting) : ?string { return $this->driver->strict_mode_query($setting); } /** * Get minimum search length. * This is broadly MySQL-specific. For other databases we will usually return 4, although there may truly not be a limit on it. * * @return integer Search length */ public function get_minimum_search_length() : int { $this->ensure_connected(); return $this->driver->get_minimum_search_length($this->connection_read); } }