DB Class
<?php
/**
 * @author Val Kornea
 * @license http://www.kornea.com Please do not copy verbatim without attribution.
 * @uses PHP 7.1+ (void return types, nullable return types)
 * @uses PHP 7 (scalar type declarations, return type declarations)
 *
 * To use the DB class, set the environment variables referenced in `getConnection()`: DB_USER, DB_PASSWD, DB_NAME, DB_HOST
 *
 * <code>
 * $row = DB::getRow($query);
 *
 * $result = DB::query($query);
 * while ($row = DB::getRow($result)) {}
 *
 * $scalar = DB::getCell($query);
 *
 * $insert_id = DB::insert('table_name', $key_value_map);
 *
 * $key_value_map = [
 *     'my_column' => $_POST['my_field'],    // automatically escaped
 *     'inserted'  => DB::verbatim('now()')  // suppress auto-escaping
 * ];
 *
 * $affected_rows = DB::update('table_name', $key_value_map, $where);
 *
 * $where = ['email' => $_REQUEST['email']];                // array autoescaped by DB::update()
 * $where = 'email="' .DB::escape($_REQUEST['email']) .'"'; // strings require manual escaping
 * DB::where(['email' => $_REQUEST['email']]);              // get an escaped string from an array
 * </code>
 */
/** Interface DB_Interface lists the main public methods of the DB class. */
interface DB_Interface {
    static function escape (string $string): string;
    static function query ($query_or_queries);
    static function getRow ($query_or_result): ?array;
    static function getRowValues ($query_or_result): ?array;
    static function getColumnNames ($result_or_table): ?array;
    static function getCell ($query_or_result);
    static function getColumn ($query_or_result): ?array;
    static function getTable ($query_or_result): ?array;
    static function getKeyValueMap ($query_or_result): ?array;
    static function getIndexedTable ($query_or_result, string $index_field_name = null): ?array;
    static function insert (string $table_name, array $key_value_map): int;
    static function update (string $table_name, array $key_value_map, $where): int;
    static function select (string $table_name, $columns, $where = null): mysqli_result;
    static function verbatim (string $string): stdClass;
    static function datetime ($time_string_or_timestamp = 'now', string $component = 'datetime'): string;
    static function where (array $key_value_map): string;
    static function not ($var): stdClass;
    static function getNumRows ($query_or_result): int;
    static function getInsertId (string $query = null): int;
    static function getAffectedRows (string $query = null): int;
    static function getConnectionId (): int;
    static function getLastQuery (): ?string;
    static function log (bool $enable_logging = true): ?array;
    static function startTransaction (): void;
    static function endTransaction (bool $commit = true): void;
} // DB_Interface
class DB implements DB_Interface {
    /**
     * @var string $lastQuery Retrieve by calling `DB::getLastQuery()`.
     * @see DB::getLastQuery(), DB::query()
     */
    protected static $lastQuery = '';
    /**
     * @var array|null $log Call `DB::log()` to toggle logging and retrieve logs.
     * @see DB::log(), DB::query()
     */
    protected static $log = null;
    /**
     * @var mysqli $connection The connection established by `static::getConnection()`.
     * @see DB::getConnection()
     * @link http://www.php.net/manual/en/class.mysqli.php
     */
    protected static $connection = null;
    /**
     * @return mysqli Connection object.
     * @throws RuntimeException If cannot connect.
     * @see DB::query()
     * @link http://www.php.net/manual/en/class.mysqli.php
     */
    protected static function getConnection (): mysqli {
        if (!static::$connection) {
            $host   = getenv('DB_HOST') ?: '127.0.0.1';
            $user   = getenv('DB_USER');
            $passwd = getenv('DB_PASSWD');
            $dbname = getenv('DB_NAME');
            /** @link http://www.php.net/manual/en/mysqli.construct.php **/
            static::$connection = @new mysqli($host, $user, $passwd, $dbname);
            if (static::$connection->connect_error) {
                throw new RuntimeException(static::$connection->connect_error, static::$connection->connect_errno);
            }
            static::$connection->set_charset('utf8mb4');
        }
        return static::$connection;
    } // getConnection
    /**
     * Executes query and (for SELECT, SHOW, DESCRIBE, and EXPLAIN queries) returns result object to be passed to `DB::getRow()` etc.
     * Multiple queries can be executed by passing an array of query strings, in which case the return value is null.
     * <code>
     * $result = DB::query($query);
     * while ($row = DB::getRow($result)) {}
     * </code>
     * @param string|array $query_or_queries
     * @return mysqli_result|null Result object or `null` for queries like `insert` and `update`.
     * @throws TableDoesNotExistException If table does not exist.
     * @throws RuntimeException If query fails for another reason such as syntax errors.
     * @see DB::getRow(), DB::getCell(), DB::insert(), DB::update()
     * @link http://www.php.net/manual/en/class.mysqli-result.php
     * @link http://www.php.net/manual/en/mysqli.query.php
     */
    public static function query ($query_or_queries): ?mysqli_result {
        if (is_array($query_or_queries)) {
            $queries = $query_or_queries;
            foreach ($queries as $query) {
                static::query($query);
            }
            return null;
        }
        $query = $query_or_queries;
        static::$lastQuery = $query;
        if (is_array(static::$log)) {
            static::$log[] = $query;
        }
        $result = static::getConnection()->query($query);
        if ($result === false) {
            $error_message = static::getConnection()->error ." in query::: $query";
            $error_code    = static::getConnection()->errno;
            if ($error_code == 1146) {
                throw new TableDoesNotExistException($error_message, $error_code);
            } else {
                throw new RuntimeException($error_message, $error_code);
            }
        }
        if ($result === true) {
            return null;
        }
        return $result;
    } // query
    /**
     * Returns a DB row as an associative array.
     * <code>
     * $row = DB::getRow($query);
     * // or
     * $result = DB::query($query);
     * while ($row = DB::getRow($result)) {}
     * </code>
     * @param string|mysqli_result $query_or_result
     * @return array DB row as an associative array.
     * @see DB::getRowValues(), DB::query(), DB::getColumn(), DB::getCell(), DB::getTable()
     * @link http://www.php.net/manual/en/mysqli-result.fetch-assoc.php
     */
    public static function getRow ($query_or_result): ?array {
        $query = is_string($query_or_result) ? $query_or_result : null;
        $result = $query ? static::query($query) : $query_or_result;
        $row = $result->fetch_assoc();
        if ($query) {
            $result->free();
        }
        return $row;
    } // getRow
    /**
     * Same as `DB::getRow()`, except the values aren't keyed by column names.
     * <code>
     * $result = DB::query($query);
     * while ($csv_fields = DB::getRowValues($result)) {
     *     fputcsv($handle, $csv_fields);
     * }
     * </code>
     * @param string|mysqli_result $query_or_result
     * @return array DB row as an enumerated array.
     * @see DB::getRow()
     * @link http://us1.php.net/manual/en/mysqli-result.fetch-row.php
     */
    public static function getRowValues ($query_or_result): ?array {
        $query = is_string($query_or_result) ? $query_or_result : null;
        $result = $query ? static::query($query) : $query_or_result;
        $row_values = $result->fetch_row();
        if ($query) {
            $result->free();
        }
        return $row_values;
    } // getRowValues
    /**
     * For queries that return a single value like counts and yes/no queries.
     * <code>
     * $query = 'select count(*) from users where company_id = 20';
     * $company_user_count = DB::getCell($query);
     * </code>
     * Note that a query that returns a `null` value is indistinguishable from one
     * that returns no rows; therefore a pattern like this is possible:
     * <code>
     * $query = 'select might_be_null from users where email = "someone@example.com"';
     * $result = DB::query($query);
     * if (0 == DB::getNumRows($result)) {
     *     die("No such email address.");
     * }
     * $might_be_null = DB::getCell($result);
     * </code>
     * @param string|mysqli_result $query_or_result
     * @return mixed Scalar value of first field in the result.
     * @see DB::getRow(), DB::getColumn(), DB::getTable()
     */
    public static function getCell ($query_or_result) {
        $query = is_string($query_or_result) ? $query_or_result : null;
        $result = $query ? static::query($query) : $query_or_result;
        $row = $result->fetch_row();
        $scalar = $row ? $row[0] : null;
        if ($query) {
            $result->free();
        }
        return $scalar;
    } // getCell
    /**
     * Get a single column's values. Useful for selecting lists of IDs, values to implode(), etc.
     * <code>
     * $company_users = DB::getColumn('select user_id from users where company_id = 20');
     * </code>
     * @param string|mysqli_result $query_or_result
     * @return array First column's values.
     * @see DB::getKeyValueMap(), DB::getRow(), DB::getCell(), DB::getTable()
     */
    public static function getColumn ($query_or_result): ?array {
        $column = []; // return value
        $query = is_string($query_or_result) ? $query_or_result : null;
        $result = $query ? static::query($query) : $query_or_result;
        while ($row = $result->fetch_row()) {
            $column[] = $row[0];
        }
        if ($query) {
            $result->free();
        }
        return $column;
    } // getColumn
    /**
     * Use `query()` and `getRow()` instead whenever possible (memory efficiency).
     * Useful for fetching data to pass to parts of the system that can't work with
     * MySQL result objects, such as AJAX scripts.
     * @param string|mysqli_result $query_or_result
     * @return array Entire result set as array of rows.
     * @see DB::getIndexedTable(), DB::getRow()
     */
    public static function getTable ($query_or_result): ?array {
        $table = []; // return value
        $query = is_string($query_or_result) ? $query_or_result : null;
        $result = $query ? static::query($query) : $query_or_result;
        while ($row = $result->fetch_assoc()) {
            $table[] = $row;
        }
        if ($query) {
            $result->free();
        }
        return $table;
    } // getTable
    /**
     * Accepts a query that selects exactly two columns and returns an associative
     * array of the second column's values indexed by the first column's values,
     * same format as `$_GET` and as expected by `http_build_query()`.
     * <code>
     * $cc = DB::getKeyValueMap('select email, name from users where company_id = 20')
     * </code>
     * @param string|mysqli_result $query_or_result Should select two columns.
     * @return array Second column's values indexed by first column's values.
     * @see DB::getColumn(), DB::getIndexedTable()
     */
    public static function getKeyValueMap ($query_or_result): ?array {
        $key_value_map = []; // return value
        $query = is_string($query_or_result) ? $query_or_result : null;
        $result = $query ? static::query($query) : $query_or_result;
        while ($row = $result->fetch_row()) {
            $key_value_map[$row[0]] = $row[1];
        }
        if ($query) {
            $result->free();
        }
        return $key_value_map;
    } // getKeyValueMap
    /**
     * Returns rows indexed by the first column (which is excluded from the rows). Useful for selecting rows indexed by
     * their IDs. Specify the optional $index_field_name parameter to index by a field other than the first column.
     * @param string|mysqli_result $query_or_result
     * @param string|null $index_field_name optional default first column
     * @return array
     * @see DB::getTable(), DB::getKeyValueMap()
     */
    public static function getIndexedTable ($query_or_result, string $index_field_name = null): ?array {
        $indexed_table = []; // return value
        $query = is_string($query_or_result) ? $query_or_result : null;
        $result = $query ? static::query($query) : $query_or_result;
        if (!$index_field_name) {
            $index_field_name = static::getColumnNames($result)[0];
        }
        while ($row = $result->fetch_assoc()) {
            $index_value = $row[$index_field_name];
            unset($row[$index_field_name]);
            $indexed_table[$index_value] = $row;
        }
        if ($query) {
            $result->free();
        }
        return $indexed_table;
    } // getIndexedTable
    /**
     * Returns same thing as `array_keys(DB::getRow($result))`, but without having to actually
     * fetch a row and forward the internal result pointer. Useful when quickly printing HTML
     * table header rows of arbitrary queries during debugging. Can also be a table name to get
     * a list of its columns. It makes no sense for this method to accept queries.
     * <code>
     * foreach (DB::getColumnNames($result) as $field_name) {
     *     echo "<th>$field_name</th>";
     * }
     * </code>
     * @param mysqli_result|string $result_or_table
     * @return array
     * @link http://www.php.net//manual/en/mysqli-result.fetch-field-direct.php
     */
    public static function getColumnNames ($result_or_table): ?array {
        if (is_string($result_or_table)) {
            return static::getColumn("show columns from `" . static::escape($result_or_table) . "`");
        } else {
            $result = $result_or_table;
            $column_names = [];
            for ($i = 0; $i < $result->field_count; $i++) {
                $column_names[] = $result->fetch_field_direct($i)->name;
            }
            return $column_names;
        }
    } // getColumnNames
    /**
     * Secures a string from SQL injection attacks.
     * `DB::insert()` and `DB::update()` escape arrays automatically.
     * If you are writing a `SELECT` query that contains no `OR` or `LIKE` conditions,
     * you can pass your array of conditions to `DB::where()` in order to get
     * an auto-escaped string; otherwise you must remember to escape manually.
     * <code>
     * $query = 'select user_id from users where email="' .DB::escape($_POST['email']) .'"';
     * </code>
     * @param string $string
     * @return string SQL-escaped string.
     * @see DB::verbatim()
     * @link http://www.php.net/manual/en/mysqli.real-escape-string.php
     */
    public static function escape (string $string): string {
        return static::getConnection()->real_escape_string($string);
    } // escape
    /**
     * Wraps your string into an object that keeps it from being auto-escaped by methods like `insert()` and `update()`.
     * <code>
     * $new_row['inserted'] = DB::verbatim('now()');
     * </code>
     * @param string $string Typically 'now()' or such.
     * @return stdClass Special object that suppresses autoescaping for your string.
     * @throws InvalidArgumentException If argument is not a string.
     * @see DB::insert(), DB::update(), DB::escape(), DB::datetime()
     */
    public static function verbatim (string $string): stdClass {
        return (object)['verbatimString' => $string];
    } // verbatim
    /**
     * <code>
     * DB::datetime() === date('Y-m-d H:i:s');
     * DB::datetime($timestamp) === date('Y-m-d H:i:s', $timestamp);
     * DB::datetime('-6 months') === date('Y-m-d H:i:s', strtotime('-6 months'));
     * DB::datetime('now', 'date') === date('Y-m-d');
     * DB::datetime('now', 'time') === date('H:i:s');
     * </code>
     * @param string|int $time_string_or_timestamp A string understood by strtotime() (defaults to 'now'), or a unix timestamp.
     * @param string $component 'datetime', 'date', 'time'
     * @return string MySQL datetime, or date or time depending on $component
     */
    public static function datetime ($time_string_or_timestamp = 'now', string $component = 'datetime'): string {
        $timestamp = is_numeric($time_string_or_timestamp) ? $time_string_or_timestamp : strtotime($time_string_or_timestamp);
        switch ( $component ) {
            case 'datetime':
            default:
                $format = 'Y-m-d H:i:s';
                break;
            case 'date':
                $format = 'Y-m-d';
                break;
            case 'time':
                $format = 'H:i:s';
                break;
        }
        return date($format, $timestamp);
    } // datetime
    /**
     * PHP special values like `null`, `true`, and `false` return corresponding MySQL keywords.
     * Strings get escaped and enclosed in quotes. Arrays become escaped comma-separated strings (sets).
     * Integers and floats remain unaltered. This method understands `DB::verbatim()`, and methods
     * like `insert()` and `update()` rely on it.
     * @param string|int|float|object|null|bool $php_value
     * @return string|int|float
     * @throws InvalidArgumentException If argument type is unrecognized.
     * @see DB::verbatim(), DB::getSetClause(), DB::where(), DB::getInClause()
     */
    public static function getSqlValue ($php_value) {
        if ( is_string($php_value) ) {
            return '"' .static::escape($php_value) .'"';
        }
        if ( is_int($php_value) or is_float($php_value) ) {
            return $php_value;
        }
        if ($php_value === null) {
            return 'null';
        }
        if ($php_value === true) {
            return 'true';
        }
        if ($php_value === false) {
            return 'false';
        }
        if ( is_object($php_value) ) {
            $specialObject = $php_value;
            if (!empty($specialObject->verbatimString)) { /* @see DB::verbatim() */
                return $specialObject->verbatimString;
            }
        }
        if ( is_array($php_value) ) {
            $escaped_set_values = [];
            foreach ($php_value as $set_value) {
                $escaped_set_values[] = static::escape($set_value);
            }
            $escaped_set_values_string = '"' .implode(',', $escaped_set_values) .'"';
            return $escaped_set_values_string;
        }
        throw new InvalidArgumentException("Unexpected argument type: " .gettype($php_value));
    } // getSqlValue
    /**
     * Creates a `SET` clause string from an associative array. Escapes values automatically,
     * except for those that have been passed through `DB::verbatim()`.
     * Used by `DB::insert()` and `DB::update()`.
     * @param array $key_value_map
     * @return string Set clause without the word `SET`.
     * @see DB::insert(), DB::update(), DB::verbatim()
     */
    public static function getSetClause (array $key_value_map): string {
        $set = [];
        foreach ($key_value_map as $column_name => $field_value) {
            $set[] = static::escape($column_name) .' = ' .static::getSqlValue($field_value);
        }
        $set = implode("\n\t,", $set);
        return $set;
    } // getSetClause
    /**
     * Inserts row and returns its ID. Escapes values automatically except for those that
     * have been passed through `DB::verbatim()`. It is possible to insert multiple rows,
     * by passing an array of rows, such as returned by `DB::getTable()`. In this case the
     * returned insert ID is that of the first row inserted. Since all rows are inserted
     * using a single query, the subsequent rows are guaranteed to have sequential insert
     * IDs (note that all rows must define the same columns, in the same order).
     * @param string $table_name
     * @param array $key_value_map
     * @param bool $replace_duplicates
     * @return int Insert ID.
     * @see DB::verbatim(), DB::update()
     */
    public static function insert ( string $table_name, array $key_value_map, $replace_duplicates = false ) : int {
        $command = $replace_duplicates ? 'replace' : 'insert';
        $has_multiple_rows = isset( $key_value_map[0] );
        if ( ! $has_multiple_rows ) {
            return static::getInsertId( "$command into " . static::escape($table_name) . ' set ' . static::getSetClause($key_value_map) );
        }
        $escaped_column_names = [];
        foreach ( array_keys( $key_value_map[0] ) as $loop_column ) {
            $escaped_column_names[] = static::escape( $loop_column );
        }
        $escaped_column_names = implode( ', ', $escaped_column_names );
        $escaped_values_lists = [];
        foreach ( $key_value_map as $loop_row ) {
            $escaped_values_list = [];
            foreach ( $loop_row as $loop_field_value ) {
                $escaped_values_list[] = static::getSqlValue( $loop_field_value );
            }
            $escaped_values_list = implode( "\n\t,", $escaped_values_list );
            $escaped_values_lists[] = "($escaped_values_list)";
        }
        $escaped_values_lists = implode( ', ', $escaped_values_lists );
        $query = "$command into " . static::escape( $table_name ) . " ($escaped_column_names) values $escaped_values_lists";
        return static::getInsertId( $query );
    } // insert
    /**
     * <code>
     * // the most common way of getting the insert ID
     * $insert_id = DB::insert( 'table_name', $row );
     * // this functions lets us do this:
     * $insert_id = DB::getInsertId( $query );
     * // and this:
     * $affected_rows = DB::getAffectedRows("
     *     update users set status = 'processing'
     *     where status = 'pending' and last_insert_id( user_id )
     *     limit 1"
     * );
     * if ( $affected_rows ) {
     *     $user_id = DB::getInsertId();
     * }
     * </code>
     * Returns the ID generated by an INSERT or UPDATE query on a table with
     *     a column having the AUTO_INCREMENT attribute.
     * In the case of a multiple-row INSERT statement,
     *     it returns the first automatically generated value that was successfully inserted.
     * Performing an INSERT or UPDATE statement using the LAST_INSERT_ID() MySQL function will
     *     also modify the value returned by mysqli_insert_id().
     * If LAST_INSERT_ID(expr) was used to generate the value of AUTO_INCREMENT,
     *     it returns the value of the last expr instead of the generated AUTO_INCREMENT value.
     * Returns 0 if the previous statement did not change an AUTO_INCREMENT value.
     * `DB::getInsertId()` must be called immediately after the statement that generated the value.
     * `DB::insert()` returns this automatically.
     * If the optional $query argument is provided, the query will be executed first.
     * @link http://www.php.net/manual/en/mysqli.insert-id.php
     */
    public static function getInsertId ( string $query = null ) : int {
        if ( $query ) {
            static::query( $query );
        }
        return static::getConnection()->insert_id;
    } // getInsertId
    /**
     * @param array $values
     * @return string `IN` clause without the word `in`.
     * @see DB::verbatim()
     */
    public static function getInClause (array $values): string {
        $in = [];
        foreach ($values as $loop_value) {
            $in[] = static::getSqlValue($loop_value);
        }
        $in = implode(', ', $in);
        return $in;
    } // getInClause
    /**
     * Most `WHERE` clauses do not contain `LIKE` or `OR` conditions but merely a number of `AND`
     * conditions. Such `WHERE` clauses can be expressed as associative arrays and passed through
     * this method to get the corresponding `WHERE` clause string (without the word `WHERE`). This
     * method escapes values automatically unless they have been passed through `DB::verbatim()`.
     * This method permits `DB::update()` to accept an array as its `WHERE` clause; it can also be
     * used as a generic helper when constructing queries. Arrays are treated as `IN` clauses.
     * Null values evaluate to `is null`, but if you need `is not null`, use `DB::not(null)`.
     * <code>
     * $where = ['column' => $_GET['field']];
     * $query = 'select * from table where ' .DB::where($where);
     * </code>
     * @param array $key_value_map
     * @return string MySQL where clause without the word `WHERE`.
     * @see DB::update(), DB::verbatim()
     */
    public static function where (array $key_value_map): string {
        $where = [];
        foreach ($key_value_map as $column_name => $field_value) {
            if ($field_value === null) {
                $where[] = static::escape($column_name) .' is null';
                continue;
            }
            if (is_array($field_value)) {
                $where[] = static::escape($column_name) .' in (' .static::getInClause($field_value) .')';
                continue;
            }
            if (is_object($field_value)) {
                $specialObject = $field_value;
                if (!empty($specialObject->notValueIsSet)) {
                    $not_value = $specialObject->notValue;
                    if ($not_value === null) {
                        $where[] = static::escape($column_name) .' is not null';
                    } elseif (is_array($not_value)) {
                        if ($not_value) {
                            $where[] = static::escape($column_name) .' not in (' .static::getInClause($not_value) .')';
                        }
                    } else {
                        $where[] = static::escape($column_name) .' != ' .static::getSqlValue($not_value);
                    }
                }
                if (!empty($specialObject->verbatimString)) {
                    $where[] = static::escape($column_name) .' = ' .$specialObject->verbatimString;
                }
                continue;
            }
            $where[] = static::escape($column_name) .' = ' .static::getSqlValue($field_value);
        } // foreach $key_value_map
        $where = implode("\n\tand ", $where);
        return $where;
    } // where
    /**
     * <code>
     * $where = DB::where([
     *     'column_a'  => DB::not('my_string')       // !=
     *     ,'column_b' => DB::not(null)              // is not null
     *     ,'column_c' => DB::not(['this', 'that'])  // not in
     * ]);
     * // $where now looks like:
     * // "column_a != 'my_string' and column_b is not null and column_c not in('this', 'that')"
     * </code>
     * @param mixed $var
     * @return stdClass
     */
    public static function not ($var): stdClass {
        return (object)['notValue' => $var, 'notValueIsSet' => true];
    } // not
    /**
     * Values will be escaped automatically except for those passed through `DB::verbatim()`.
     * If the `WHERE` clause is an array, it will be autoescaped the same way;
     * but if it's a string, remember to perform your own escaping.
     * <code>
     * $update = [
     *   'column_name' => $_POST['field_name'], // automatically escaped
     *   'updated'     => DB::verbatim('now()') // suppress auto-escaping
     * ];
     * $where = ['column_name' => $_POST['field_name']];                // autoescaped
     * $where = 'column_name="' .DB::escape($_POST['field_name']) .'"'; // also possible
     * $affected_rows = DB::update('users', $update, $where);
     * </code>
     * @param string $table_name
     * @param array $key_value_map
     * @param string|array $where
     * @return int Number of rows affected by the update query.
     * @see DB::verbatim(), DB::insert()
     */
    public static function update (string $table_name, array $key_value_map, $where): int {
        if (is_array($where)) {
            $where = static::where($where);
        }
        $query = 'update `' .static::escape($table_name) .'` set ' .static::getSetClause($key_value_map) ."\nwhere $where";
        return static::getAffectedRows($query);
    } // update
    /**
     * In the model library we often have table name, columns, and condition as variables. We could
     * manually construct query strings from variables and pass the strings to DB::query(). This
     * method is a shortcut way of doing the same thing; it cannot handle complex queries.
     * @param string $table_name
     * @param array|string $columns
     * @param array|string $where
     * @return mysqli_result
     */
    public static function select (string $table_name, $columns, $where = null): mysqli_result {
        if (is_string($columns)) {
            $columns = [$columns];
        }
        $columns = implode(', ', $columns);
        $query = "select $columns from $table_name";
        if (is_array($where)) {
            $where = static::where($where);
        }
        if ($where) {
            $query = "$query where $where";
        }
        $result = static::query($query);
        return $result;
    } // select
    /**
     * Returns the number of rows affected by the last `INSERT`, `UPDATE`, `REPLACE` or
     * `DELETE` query. `DB::update()` returns this number automatically. If the optional
     * `$query` argument is provided, the query will be executed first.
     * <code>
     * DB::query($query);
     * $affected_rows = DB::getAffectedRows();
     * // or
     * $affected_rows = DB::getAffectedRows($query);
     * </code>
     * @param null|string $query optional
     * @return int "number of rows affected by the last INSERT, UPDATE, REPLACE or DELETE query (php.net)"
     * @link http://www.php.net/manual/en/mysqli.affected-rows.php
     */
    public static function getAffectedRows (string $query = null): int {
        if ($query) {
            static::query($query);
        }
        return static::getConnection()->affected_rows;
    } // getAffectedRows
    /**
     * Gets number of rows within provided result object or returned by provided query.
     * Accepting query strings permits this method to be used for getting yes/no answers from the database.
     * <code>
     * $result = DB::query($query);
     * $num_rows = DB::getNumRows($result);
     * // or
     * $num_rows = DB::getNumRows($query);
     * </code>
     * @param mysqli_result|string $query_or_result Such as returned by DB::query().
     * @return int Number of rows in result.
     * @link http://www.php.net//manual/en/mysqli-result.num-rows.php
     */
    public static function getNumRows ($query_or_result): int {
        $query = is_string($query_or_result) ? $query_or_result : null;
        $result = $query ? static::query($query) : $query_or_result;
        $num_rows = $result->num_rows;
        if ($query) {
            $result->free();
        }
        return $num_rows;
    } // getNumRows
    /**
     * Useful for locking tables.
     * @return int
     */
    public static function getConnectionId (): int {
        return (int)static::getCell('select connection_id()');
    } // getConnectionId
    /**
     * This includes queries executed by methods such as `DB::insert()` and `DB::update()`.
     * @return string The last SQL query executed by the DB class.
     * @see DB::query(), static::$lastQuery
     */
    public static function getLastQuery (): ?string {
        return static::$lastQuery;
    } // getLastQuery
    /**
     * Empties the query log and returns its contents, and either enables or disables logging.
     * <code>
     * DB::log();
     * //etc
     * $log = DB::log(false);
     * </code>
     * @param boolean $enable_logging Whether to enable logging.
     * @return array|null Queries logged so far.
     * @see DB::$log, DB::query()
     */
    public static function log (bool $enable_logging = true): ?array {
        $query_log = static::$log;
        static::$log = $enable_logging ? [] : null;
        return $query_log;
    } // log
    public static function startTransaction (): void {
        static::getConnection()->autocommit(false);
    } // startTransaction
    /** @param bool $commit true (default) to commit, false to do a rollback **/
    public static function endTransaction (bool $commit = true): void {
        if ($commit) {
            static::getConnection()->commit();
        } else {
            static::getConnection()->rollback();
        }
        static::getConnection()->autocommit(true);
    } // endTransaction
    /**
     * Runs various queries, see if something crashes.
     * @return array queries
     * @throws Exception
     */
    public static function test (): array {
        DB::log();
        DB::query('
            create temporary table dbtest (
                dbtest_id   integer unsigned primary key auto_increment,
                some_id     integer unsigned,
                some_string varchar(255),
                some_flag   boolean,
                some_etc    varchar(255) null,
                updated     timestamp null default null on update current_timestamp,
                inserted    timestamp default current_timestamp
            )'
        );
        $insert = [
            'some_id'     => 20,
            'some_string' => 'test string',
            'some_flag'   => true,
            'some_etc'    => null,
            'inserted'    => DB::datetime(),
        ];
        $dbtest_id = DB::insert('dbtest', $insert);
        if ($dbtest_id != 1) {
            throw new Exception("Invalid \$dbtest_id: $dbtest_id.");
        }
        $count = DB::getCell('select count(*) from dbtest');
        if ($count != 1) {
            throw new Exception("Invalid \$count: $count.");
        }
        $update = ['some_string' => 'updated test string'];
        $where = ['dbtest_id' => $dbtest_id];
        $affected_rows = DB::update('dbtest', $update, $where);
        if ($affected_rows != 1) {
            throw new Exception("Invalid \$affected_rows: $affected_rows.");
        }
        $query = 'select * from dbtest where dbtest_id = "' .DB::escape($dbtest_id) .'"';
        $row = DB::getRow($query);
        if (!is_array($row) or empty($row)) {
            throw new Exception("Invalid \$row: $row.");
        }
        $row_values = DB::getRowValues($query);
        if (!is_array($row_values) or empty($row_values)) {
            throw new Exception("Invalid \$row_values: $row_values.");
        }
        $query = 'select * from dbtest';
        $result = DB::query($query);
        while ( $row = DB::getRow($result) ) {
            break;
        }
        DB::query('drop temporary table dbtest');
        return DB::log(false);
    } // test
} // DB
class TableDoesNotExistException extends RuntimeException {
    /* This exists to differentiate this exception type, since some code knows how to handle it. */
} // TableDoesNotExistException