request = request::get_instance(); $this->setting = setting::get_instance(); parent::__construct( $this->setting->get('database_host'), $this->setting->get('database_username'), $this->setting->get('database_password') ); // Have the database driver return ints and floats in PHP native types. PHP // does not have a native type for decimals so that gets left behind. parent::options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, true); // $this->connect_error = 'this is broken'; if($this->connect_error !== null) { throw new exception( 'Could not connect to database.', 1200, true, [ 'database_error' => $this->connect_error ] ); } $database_name = $this->setting->get('database_name'); if($database_name !== null) { $success = $this->select_db($database_name); if($success === false) { throw new exception( 'Could not select database.', 1208, true, [ 'database_error' => $this->error ] ); } } } /** * Upon destruction of this class, close the open transaction. I check to * make sure one is open, but that should really always be the case since * one gets opened regardless. */ public function __destruct() { if($this->transaction_started === true) { $this->commit_transaction(); } } /** * Use this function to instantiate this class instead of calling new * database() (which isn't allowed anyways). This avoids confusion from * trying to use dependency injection by passing an instance of this class * around everywhere. It also keeps a single connection open to the database * for the current API call. * * @return database A new database object or the already created one. */ public static function get_instance() { if(isset(self::$instance) === false) { self::$instance = new self(); } return self::$instance; } /** * Second instance; see comment on the class member declaration. * * @return database A new database object or the already created one. */ public static function get_transactionless_instance() { if(isset(self::$transactionless_instance) === false) { self::$transactionless_instance = new self(); self::$transactionless_instance->disable_transactions(); } return self::$transactionless_instance; } /** * Start a transaction. * * @throws \Exception If the transaction fails to start. */ public function start_transaction() { if($this->transaction_started === false) { $result = $this->query('start transaction'); if($result === false) { throw new exception('Failed to start database transaction.', 1201); } $this->transaction_started = true; } } /** * Commit a transaction. * * @throws \Exception If the transaction fails to commit. */ public function commit_transaction() { if($this->transaction_started === true) { $this->transaction_started = false; $result = $this->query('commit'); if($result === false) { throw new exception('Failed to commit database transaction.', 1202); } } } /** * Rollback the current transaction. This is exposed because the exception * handler needs to rollback the current transaction when it runs. * * @throws \Exception If the transaction fails to rollback. */ public function rollback_transaction() { if($this->transaction_started === true) { $this->transaction_started = false; $result = $this->query('rollback'); if($result === false) { throw new exception('Failed to rollback database transaction.', 1203); } } } /** * Escape a value to be used in a query. Only necessary when doing custom * queries. All helper functions like select, insert, and update escape * values for you using this function. * * @param mixed $value The value to escape. Boolean true and false are * converted to int 1 and 0 respectively. * @param bool $basic If overridden to true, just return real_escape_string * of $value. If left alone or set to false, return a value appropriate to * be used like "set foo=$bar" as it will have single quotes around it if * necessary. * * @return string The escaped value. */ public function escape($value, $basic = false) { if($basic === true) { return $this->real_escape_string($value); } if($value === null) { return 'null'; } else if($value === true) { return '1'; } else if($value === false) { return '0'; } else if(is_int($value) === true) { return $value; } else { return '"' . $this->real_escape_string($value) . '"'; } } /** * Helper function to secure names of tables & columns passed to this class. * First of all, these identifiers must be a valid word. Backticks are also * placed around the identifier in all cases to allow the use of MySQL * keywords as table and column names. * * @param string $identifier The identifier to escape * * @throws \Exception If the identifier does not match the character class * [A-Za-z0-9_]. That would make it invalid for use in MySQL. * * @return string The escaped identifier. */ public function escape_identifier($identifier) { if(preg_match('/^\w+$/', $identifier) === 1) { return '`' . $identifier . '`'; } else { throw new exception( 'Query identifier is invalid.', 1204, true, [ 'identifier' => $identifier ] ); } } /** * Builds a properly escaped string for the 'where column=value' portion of * a query. * * @param string $column The query column. * @param mixed $value The value(s) to compare against. You can use null, an * array, or any other value here and the appropriate comparison (is null, * in, =) will be used. * * @throws \Exception If an invalid operator was specified. * * @return string The appropriate escaped string. Examples: `foo` is null * `foo` in(1,2,3) `foo`='bar' */ public function column_equals_value_where($column, $value) { if($value === null) { return $this->escape_identifier($column) . ' is null'; } else if(is_array($value) === true) { if(isset($value['operator']) === true) { if(in_array($value['operator'], ['>', '<', '=', '>=', '<=', 'between']) === false) { throw new exception('Invalid operator', 1213); } if($value['operator'] === 'between') { return $this->escape_identifier($column) . ' between ' . $this->escape($value['value'][0]) . ' and ' . $this->escape($value['value'][1]); } else { return $this->escape_identifier($column) . $value['operator'] . $this->escape($value['value']); } } else { return $this->escape_identifier($column) . ' in (' . implode(',', array_map([$this, 'escape'], $value)) . ')'; } } else { return $this->escape_identifier($column) . '=' . $this->escape($value); } } /** * Builds a properly escaped string for the 'set column=value' portion of a * query. * * @param string $column The query column. * @param mixed $value The value to set. * * @return string The appropriate escaped string. Examples: `foo`='bar' * `foo`=5 */ private function column_equals_value_set($column, $value) { return $this->escape_identifier($column) . '=' . $this->escape($value); } /** * Performs a query on the database. This function is available publicly for * the case when the standard select, insert, and update don't quite cut it. * * The exceptions are broken up somewhat by type to make it easier to catch * and handle these exceptions if desired. * * This will start a transaction if the query begins with 'insert', * 'update', or 'delete' and a transaction has not already been started. * * IMPORTANT: YOU MUST SANTIZE YOUR OWN DATABASE QUERY WHEN USING THIS * FUNCTION DIRECTLY. THIS FUNCTION DOES NOT DO IT FOR YOU. * * @param string $query The query to execute. * @param int $resultmode Just here because PHP requires it. * * @throws DuplicateEntryException if the query failed due to a duplicate * entry (unique key violation) * @throws \Exception If the query failed and was not caught by any other * exception types. * * @return mixed The result directly from $mysqli->query. */ public function query($query, $result_mode = \MYSQLI_STORE_RESULT): \mysqli_result|bool { // If this was an insert, update or delete, start a transaction $query_type = substr(trim($query), 0, 6); if( in_array($query_type, ['insert', 'update', 'delete']) === true && $this->use_transactions === true ) { $this->start_transaction(); } $start = microtime(true); $result = parent::query($query); $stop = microtime(true); $this->queries[] = [ 'query' => $query, 'time' => ($stop - $start) ]; if($result === false) { $error = $this->error; $this->rollback_transaction(); throw new exception( 'Database query failed.', 1206, true, [ 'database_error' => $error, 'query' => $query ] ); } return $result; } /** * Select some columns from some table with some where clause. * * @param string $resource The resource to select from. * @param array $attributes An array of key value pairs to search by and can * include arrays if you want to search in() something. * @param array $columns The columns to return. If not specified, all * columns are returned. * @param mixed $order_by String or array of order_bys. * @param mixed $limit Number or array of numbers as arguments to the MySQL * limit clause. * * @return array An array of the database rows with the specified columns. * Even a single result will still be returned in an array of size 1. */ public function read($resource, $attributes = [], $columns = [], $order_by = [], $limit = []) { $table = $this->get_table($resource); // Build the column listing. if(count($columns) === 0) { $columns = '*'; } else { $columns = implode( ',', array_map([$this, 'escape_identifier'], $columns) ); } // Remove empty arrays from the attributes to avoid query errors on the // empty in() statement. foreach($attributes as $key => $value) { if(is_array($value) === true && count($value) === 0) { unset($attributes[$key]); } } // Build the where clause. if (count($attributes) === 0) { $where = ''; } else { $where = ' where ' . implode( ' and ', array_map( [$this, 'column_equals_value_where'], array_keys($attributes), $attributes ) ); } // Order by if (is_array($order_by) === false) { $order_by = [$order_by]; } if (count($order_by) === 0) { $order_by = ''; } else { $order_by = ' order by ' . implode( ',', array_map( [$this, 'escape_identifier'], $order_by ) ); } // Limit if (is_array($limit) === false) { $limit = [$limit]; } if (count($limit) === 0) { $limit = ''; } else { $limit = ' limit ' . implode( ',', array_map( [$this, 'escape'], $limit ) ); } // Put everything together and return the result. $query = 'select ' . $columns . ' from ' . $this->escape_identifier($table) . $where . $order_by . $limit; $result = $this->query($query); /** * Get a list of all fields that need to be cast to other data types. The * MYSQLI_OPT_INT_AND_FLOAT_NATIVE flag handles ints and floats. This * turns decimal into float and tinyint(1) into boolean. */ $float_fields = []; $boolean_fields = []; $json_fields = []; while($field_info = $result->fetch_field()) { if($field_info->type === 1 && $field_info->length === 1) { $boolean_fields[] = $field_info->name; } else if($field_info->type === 246) { $float_fields[] = $field_info->name; } else if($field_info->type === 245) { $json_fields[] = $field_info->name; } } $results = []; while($row = $result->fetch_assoc()) { // Cast if necessary. foreach($float_fields as $float_field) { if($row[$float_field] !== null) { $row[$float_field] = (float) $row[$float_field]; } } foreach($boolean_fields as $boolean_field) { if($row[$boolean_field] !== null) { $row[$boolean_field] = (boolean) $row[$boolean_field]; } } foreach($json_fields as $json_field) { $row[$json_field] = $row[$json_field] === null ? null : json_decode($row[$json_field], true); } // Diverge the converged column. if( isset($row['converged']) === true && class_exists($resource) === true && // This will also call the autoloader to make sure it's loaded isset($resource::$converged) === true ) { foreach($resource::$converged as $column => $column_properties) { if(isset($row[$column]) === true) { throw new exception('Column `' . $column . '` exists; cannot be overwritten by converged column.', 1212); } $row[$column] = (isset($row['converged'][$column]) === true) ? $row['converged'][$column] : null; } unset($row['converged']); } $results[] = $row; } return $results; } /** * Converge attributes. * * @param string $resource * @param int $id * @param array $attributes * * @return array */ private function converge_attributes($resource, $id, $attributes) { if( class_exists($resource) === true && // This will also call the autoloader to make sure it's loaded isset($resource::$converged) === true && empty($resource::$converged) === false ) { $table = $this->get_table($resource); if($id !== null) { $existing_attributes = []; $existing_attributes[$table . '_id'] = $id; $existing_row = $this->read($resource, $existing_attributes)[0]; } else { $existing_row = []; } $converged = []; foreach($resource::$converged as $column => $column_properties) { if(isset($existing_row[$column]) === true) { $converged[$column] = $existing_row[$column]; } if(array_key_exists($column, $attributes) === true) { if($attributes[$column] === null) { unset($converged[$column]); } else { switch($column_properties['type']) { case 'int': case 'float': case 'string': settype($attributes[$column], $column_properties['type']); break; } $converged[$column] = $attributes[$column]; } unset($attributes[$column]); } } $attributes['converged'] = json_encode($converged); } return $attributes; } /** * Update some columns in a table by the primary key. Doing updates without * using the primary key are supported by writing your own queries and using * the database->query() function. That should be a rare circumstance * though. * * @param string $resource The resource to update. * @param array $attributes The attributes to set. * @param array $return_mode Either "row" or "id". Specifying row will * return the newly created row (does a database read). Specifying id will * return just the ID of the created row instead of performing another query * to get the whole inserted row. * * @throws \Exception If no attributes were specified. * * @return int The updated row. */ public function update($resource, $attributes, $return_mode = 'row') { $table = $this->get_table($resource); foreach($attributes as $key => $value) { if($this->get_type($resource, $key) === 'json') { if($value === null) { $attributes[$key] = null; } else { $attributes[$key] = json_encode($value); } } } // Check for errors if(isset($attributes[$table . '_id']) === false) { throw new exception('ID is required for update.', 1214); } // Extract the ID. $id = $attributes[$table . '_id']; unset($attributes[$table . '_id']); // Check for errors if(count($attributes) === 0) { throw new exception('Updates require at least one attribute.', 1207); } // Converge the diverged attributes. $attributes = $this->converge_attributes($resource, $id, $attributes); // Build the column setting $columns = implode( ',', array_map( [$this, 'column_equals_value_set'], array_keys($attributes), $attributes ) ); // Build the where clause $where_attributes = [$table . '_id' => $id]; $where = 'where ' . implode( ' and ', array_map( [$this, 'column_equals_value_where'], array_keys($where_attributes), $where_attributes ) ); $query = 'update ' . $this->escape_identifier($table) . ' set ' . $columns . ' ' . $where; // Disallow updates in the demo. if($this->setting->is_demo() === true) { return $this->read($resource, $where_attributes)[0]; } $this->query($query); if($return_mode === 'row') { return $this->read($resource, $where_attributes)[0]; } else { return $id; } } /** * Set deleted = 1 on the database row. * * @param string $resource The table to delete from. * @param int $id The value of the primary key to delete. * * @return int The number of rows affected by the delete (could be 0). */ public function delete($resource, $id) { $table = $this->get_table($resource); $attributes = []; $attributes[$table . '_id'] = $id; $attributes['deleted'] = true; $this->update($resource, $attributes); return $this->affected_rows; } /** * Insert a row into the specified table. This only supports single-row * inserts. * * @param string $table The table to insert into. * @param array $attributes The attributes to set on the row. * @param array $return_mode Either "row" or "id". Specifying row will * return the newly created row (does a database read). Specifying id will * return just the ID of the created row. * * @return mixed Either the primary key of the inserted row or the inserted * row. If the row is not needed it's faster to return the ID to avoid * another select. */ public function create($resource, $attributes, $return_mode = 'row') { $table = $this->get_table($resource); foreach($attributes as $key => $value) { if($this->get_type($resource, $key) === 'json') { if($value === null) { $attributes[$key] = null; } else { $attributes[$key] = json_encode($value); } } } $attributes = $this->converge_attributes($resource, null, $attributes); $columns = implode( ',', array_map([$this, 'escape_identifier'], array_keys($attributes)) ); $values = implode( ',', array_map([$this, 'escape'], $attributes) ); $query = 'insert into ' . $this->escape_identifier($table) . '(' . $columns . ') values (' . $values . ')'; $this->query($query); if($return_mode === 'row') { $read_attributes = []; $read_attributes[$table . '_id'] = $this->insert_id; return $this->read($resource, $read_attributes)[0]; } else { return $this->insert_id; } } /** * Gets the number of queries that have been executed. * * @return int The query count. */ public function get_query_count() { return count($this->queries); } /** * Gets the time taken to execute all of the queries. * * @return float The total execution time. */ public function get_query_time() { $query_time = 0; foreach ($this->queries as $query) { $query_time += $query['time']; } return $query_time; } /** * Gets the time taken to execute all of the queries. * * @return float The total execution time. */ public function get_queries() { return $this->queries; } /** * Turn a resource into a table. * * @param string $resource * * @return string */ private function get_table($resource) { $class_parts = explode('\\', $resource); return end($class_parts); } /** * Get the type of a specific column. * * @param string $table The table. * @param string $column The column. * * @return string The type. */ private function get_type($resource, $column) { $table = $this->get_table($resource); // If this column is in converged, get the type from there. if( class_exists($resource) === true && // This will also call the autoloader to make sure it's loaded isset($resource::$converged) === true && isset($resource::$converged[$column]) === true ) { return $resource::$converged[$column]['type']; } // Otherwise query the entire schema (and cache it) to see what the type is. if(isset(self::$types) === false) { self::$types = []; $result = $this->query(' select `TABLE_NAME`, `COLUMN_NAME`, `DATA_TYPE` from `information_schema`.`columns` where `table_schema` = ' . $this->escape($this->setting->get('database_name')) . ' '); while($row = $result->fetch_assoc()) { self::$types[$row['TABLE_NAME'] . '.' . $row['COLUMN_NAME']] = $row['DATA_TYPE']; } } return self::$types[$table . '.' . $column]; } /** * Attempt to get a database lock. * * @param string $lock_name The lock name. * @param int $timeout How long to wait for the lock. Negative values are * forever. * * @throws \Exception If the lock could not be obtained. */ public function get_lock($lock_name, $timeout = 0) { $result = $this->query(' select get_lock(' . $this->escape($this->setting->get('database_name') . '_' . $lock_name) . ', ' . $this->escape($timeout) . ') `lock` '); $row = $result->fetch_assoc(); if($row['lock'] !== 1) { throw new exception('Could not get lock.', 1209); } } /** * Attempt to release a database lock. * * @param string $lock_name The lock name. * * @throws \Exception If the lock was not established by this thread. * @throws \Exception If the lock does not exist. */ public function release_lock($lock_name) { $result = $this->query(' select release_lock(' . $this->escape($this->setting->get('database_name') . '_' . $lock_name) . ') `lock` '); $row = $result->fetch_assoc(); if($row['lock'] === 0) { throw new exception('Lock not established by this thread.', 1210); } else if($row['lock'] === null) { throw new exception('Lock does not exist.', 1211); } } /** * Set the time zone. Useful for querying in a certain time zone so MySQL * will just handle the conversion. * * @param int $time_zone_offset Offset in minutes. */ public function set_time_zone($time_zone_offset) { $operator = $time_zone_offset < 0 ? '-' : '+'; $time_zone_offset = abs($time_zone_offset); $offset_hours = floor($time_zone_offset / 60); $offset_minutes = $time_zone_offset % 60; $this->query('SET time_zone = "' . $operator . sprintf('%d', $offset_hours) . ':' . str_pad($offset_minutes, 2, STR_PAD_LEFT) . '"'); } /** * Disable transactions for this connection. */ public function disable_transactions() { $this->use_transactions = false; } }