<?php
/**
 * @author Vasiliy Makogon, makogon-vs@yandex.ru
 * @link https://github.com/Vasiliy-Makogon/Database/
 *
 * ---------------------------------------------------------------------------------------------------------------------
 *     Библиотека для удобной и безопасной работы с СУБД MySql на базе расширения PHP mysqli.
 * ---------------------------------------------------------------------------------------------------------------------
 *
 * Библиотека имулирует технологию Prepared statement (или placeholders) - для формирования корректных SQL-запросов
 * (т.е. запросов, исключающих SQL-уязвимости), в строке запроса вместо значений пишутся специальные типизированные
 * маркеры - т.н. "заполнители", а сами данные передаются "позже", в качестве последующих аргументов основного метода,
 * выполняющего SQL-запрос - Mysql::query($sql [, $arg, $...]):
 *
 *     $getInstance->query('SELECT * FROM `table` WHERE `name` = "?s" AND `age` = ?i', $_POST['name'], $_POST['age']);
 *
 * Аргументы SQL-запроса, прошедшие через систему placeholders данного класса, экранируются специальными функциями
 * экранирования, в зависимости от типа заполнителей. Т.е. вам теперь нет необходимости заключать переменные в функции
 * экранирования типа mysqli_real_escape_string($value) или приводить их к числовому типу через (int)$value.
 *
 * Кроме того, данный класс позволяет:
 * - получать "подготовленный" SQL-запрос для отладки, т.е. запрос с реальными значениями, что невозможно сделать
 * используя "сырые" драйверы PHP типа PDO.
 * - получать список всех запросов выполненных в рамках одного подключения к Mysql-серверу.
 *
 *
 * ---------------------------------------------------------------------------------------------------------------------
 *    Режимы работы.
 * ---------------------------------------------------------------------------------------------------------------------
 *
 * Существует два режима работы класса:
 * Mysql::MODE_STRICT    - строгий режим соответствия типа заполнителя и типа аргумента.
 * Mysql::MODE_TRANSFORM - режим преобразования аргумента к типу заполнителя при несовпадении
 *                                           типа заполнителя и типа аргумента.
 *
 * Режим Mysql::MODE_TRANSFORM установлен по умолчанию и является основным для большинства приложений.
 * Если же вам нужна максимальная прозрачность операций над типами данных, производимых библиотекой Datavase,
 * установите режим Mysql::MODE_STRICT.
 *
 *
 *     MODE_STRICT
 *
 * В "строгом" режиме MODE_STRICT аргументы, передаваемые в основной метод Mysql::query(),
 * должны соответствовать типу заполнителя.
 * Например, попытка передать в качестве аргумента значение 55.5 или '55.5' для заполнителя целочисленного типа ?i
 * приведет к выбросу исключения:
 *
 * $getInstance->setTypeMode(Mysql::MODE_STRICT); // устанавливаем строгий режим работы
 * $getInstance->query('SELECT ?i', 55.5); // Попытка указать для заполнителя типа int значение типа double в шаблоне запроса SELECT ?i
 *
 * Это утверждение не относится к числам (целым и с плавающей точкой), заключенным в строки.
 * С точки зрения библиотеки, строка '123' и значение 123 являются типом int.
 *
 *
 *     MODE_TRANSFORM
 *
 * Режим MODE_TRANSFORM является "щадящим" режимом и при несоответствии типа заполнителя и типа аргумента не генерирует
 * исключение, а пытается преобразовать аргумент к нужному типу заполнителя посредством самого языка PHP.
 *
 * Допускаются следующие преобразования:
 *
 * К типу int приводятся (заполнитель ?i):
 *     - числа с плавающей точкой, представленные как строка или тип double
 *     - bool
 *     - null
 *
 * К типу double приводятся (заполнитель ?d):
 *     - целые числа, представленные как строка или тип int
 *     - bool
 *     - null
 *
 * К типу string приводятся (заполнитель ?s):
 *     - значение boolean TRUE преобразуется в строку "1", а значение FALSE преобразуется в "" (пустую строку).
 *     - значение типа numeric преобразуется в строку согласно правилам преобразования, определенным языком.
 *     - NULL преобразуется в пустую строку.
 *
 * К типу null приводятся (заполнитель ?n):
 *     - любые аргументы
 *
 * Для массивов, объектов и ресурсов преобразования не допускаются.
 *
 *
 * ---------------------------------------------------------------------------------------------------------------------
 *    Типы маркеров-заполнителей
 * ---------------------------------------------------------------------------------------------------------------------
 *
 * ?f - заполнитель имени таблицы или поля (первая буква слова field).
 *      Данный заполнитель предназначен для случаев, когда имя таблицы или поля передается в запроос через аргумент.
 *
 * ?i - заполнитель целого числа (первая буква слова integer).
 *      В режиме MODE_TRANSFORM любые скалярные аргументы принудительно приводятся к типу integer
 *      согласно правилам преобразования к типу integer в PHP.
 *
 * ?d - заполнитель числа с плавающей точкой (первая буква слова double).
 *      В режиме MODE_TRANSFORM любые скалярные аргументы принудительно приводятся к типу float
 *      согласно правилам преобразования к типу float в PHP.
 *
 * ?s - заполнитель строкового типа (первая буква слова string).
 *      В режиме MODE_TRANSFORM любые скалярные аргументы принудительно приводятся к типу string
 *      согласно правилам преобразования к типу string в PHP
 *      и экранируются с помощью функции PHP mysqli_real_escape_string().
 *
 * ?S - заполнитель строкового типа для подстановки в SQL-оператор LIKE (первая буква слова string).
 *      В режиме MODE_TRANSFORM Любые скалярные аргументы принудительно приводятся к типу string
 *      согласно правилам преобразования к типу string в PHP
 *      и экранируются с помощью функции PHP mysqli_real_escape_string() + экранирование спецсимволов,
 *      используемых в операторе LIKE (%_).
 *
 * ?n - заполнитель NULL типа (первая буква слова null).
 *      В режиме MODE_TRANSFORM любые аргументы игнорируются, заполнители заменяются на строку `NULL` в SQL запросе.
 *
 * ?A* - заполнитель ассоциативного множества для ассоциативного массива-аргумента, генерирующий последовательность
 *       пар ключ => значение.
 *       Пример: "key_1" = "val_1", "key_2" = "val_2", ...
 *
 * ?a* - заполнитель множества из простого (или также ассоциативного) массива-аргумента, генерирующий последовательность
 *       значений.
 *       Пример: "val_1", "val_2", ...
 *
 *       где * после маркера заполнителя - один из типов:
 *       - i (int)
 *       - p (float)
 *       - s (string)
 *       правила преобразования и экранирования такие же, как и для одиночных скалярных аргументов (см. выше).
 *
 * ?A[?n, ?s, ?i, ?d] - заполнитель ассоциативного множества с явным указанием типа и количества аргументов,
 *                      генерирующий последовательность пар ключ => значение.
 *                      Пример: "key_1" = "val_1", "key_2" => "val_2", ...
 *
 * ?a[?n, ?s, ?i, ?d] - заполнитель множества с явным указанием типа и количества аргументов, генерирующий
 *                      последовательность значений.
 *                      Пример: "val_1", "val_2", ...
 *
 *
 * ---------------------------------------------------------------------------------------------------------------------
 *    Ограничивающие кавчки
 * ---------------------------------------------------------------------------------------------------------------------
 *
 * Данный класс при формировании SQL-запроса НЕ занимается проставлением ограничивающих кавычек для одиночных
 * заполнителей скалярного типа, таких как ?i, ?d и ?s. Это сделано по идеологическим соображениям,
 * автоподстановка кавычек может стать ограничением для возможностей SQL.
 * Например, выражение
 *     $getInstance->query('SELECT "Total: ?s"', '200');
 * вернёт строку
 *     'Total: 200'
 * Если бы кавычки, ограничивающие строковой литерал, ставились бы автоматически,
 * то вышеприведённое условие вернуло бы строку
 *     'Total: "200"'
 * что было бы не ожидаемым поведением.
 *
 * Тем не менее, для перечислений ?as, ?ai, ?ap, ?As, ?Ai и ?Ap ограничивающие кавычки ставятся принудительно, т.к.
 * перечисления всегда используются в запросах, где наличие кавчек обязательно или не играет роли (а так ли это?):
 *
 *    $getInstance->query('INSERT INTO `test` SET ?As', array('name' => 'Маша', 'age' => '23', 'adress' => 'Москва'));
 *    -> INSERT INTO test SET `name` = "Маша", `age` = "23", `adress` = "Москва"
 *
 *    $getInstance->query('SELECT * FROM table WHERE field IN (?as)', array('55', '12', '132'));
 *    -> SELECT * FROM table WHERE field IN ("55", "12", "132")
 *
 * Также исключения составляют заполнители типа ?f, предназначенные для передачи в запрос имен таблиц и полей.
 * Аргумент заполнителя ?f всегда обрамляется обратными кавычками (`):
 *
 *    $getInstance->query('SELECT ?f FROM ?f', 'my_field', 'my_table');
 *    -> SELECT `my_field` FROM `my_table`
 */
namespace Krugozor\Database\Mysql;

use mysqli;
use mysqli_result;

class Mysql
{
    /**
     * Строгий режим типизации.
     * Если тип заполнителя не совпадает с типом аргумента, то будет выброшено исключение.
     * Пример такой ситуации:
     *
     * $getInstance->query('SELECT * FROM `table` WHERE `id` = ?i', '2+мусор');
     *
     * - в данной ситуации тип заполнителя ?i - число или числовая строка,
     *   а в качестве аргумента передаётся строка '2+мусор' не являющаяся ни числом, ни числовой строкой.
     *
     * @var int
     */
    const MODE_STRICT = 1;

    /**
     * Режим преобразования.
     * Если тип заполнителя не совпадает с типом аргумента, аргумент принудительно будет приведён
     * к нужному типу - к типу заполнителя.
     * Пример такой ситуации:
     *
     * $getInstance->query('SELECT * FROM `table` WHERE `id` = ?i', '2+мусор');
     *
     * - в данной ситуации тип заполнителя ?i - число или числовая строка,
     *   а в качестве аргумента передаётся строка '2+мусор' не являющаяся ни числом, ни числовой строкой.
     *   Строка '2+мусор' будет принудительно приведена к типу int согласно правилам преобразования типов в PHP.
     *
     * @var int
     */
    const MODE_TRANSFORM = 2;

    /**
     * Режим работы инстанцированного объекта.
     * См. описание констант self::MODE_STRICT и self::MODE_TRANSFORM.
     *
     * @var int
     */
    protected $type_mode = self::MODE_TRANSFORM;

    protected $server;

    protected $user;

    protected $password;

    protected $port;

    protected $socket;

    /**
     * Имя текущей БД.
     *
     * @var string
     */
    protected $database_name;

    /**
     * Стандартный объект соединения сервером MySQL.
     *
     * @var mysqli
     */
    protected $mysqli;

    /**
     * Строка последнего SQL-запроса до преобразования.
     *
     * @var string
     */
    private $original_query;

    /**
     * Строка последнего SQL-запроса после преобразования.
     *
     * @var string
     */
    private $query;

    /**
     * Массив со всеми запросами, которые были выполнены объектом.
     * Ключи - SQL после преобразования, значения - SQL до преобразования.
     *
     * @var array
     */
    private $queries = array();

    /**
     * Накапливать ли в хранилище $this->queries исполненные запросы.
     *
     * @var bool
     */
    private $store_queries = true;

    /**
     * Создает инстанс данного класса.
     *
     * @param string $server имя сервера
     * @param string $username имя пользователя
     * @param string $password пароль
     * @param string $port порт
     * @param string $socket сокет
     */
    public static function create($server, $username, $password, $port=null, $socket=null)
    {
        return new self($server, $username, $password, $port, $socket);
    }

    /**
     * Задает набор символов по умолчанию.
     * Вызов данного метода эквивалентен следующей установки конфигурации MySql-сервера:
     * SET character_set_client = charset_name;
     * SET character_set_results = charset_name;
     * SET character_set_connection = charset_name;
     *
     * @param string $charset
     * @return Mysql
     */
    public function setCharset($charset)
    {
        if (!$this->mysqli->set_charset($charset)) {
            throw new Exception(__METHOD__ . ': ' . $this->mysqli->error);
        }

        return $this;
    }

    /**
     * Возвращает кодировку по умолчанию, установленную для соединения с БД.
     *
     * @param void
     * @return string
     */
    public function getCharset()
    {
        return $this->mysqli->character_set_name();
    }

    /**
     * Устанавливает имя используемой СУБД.
     *
     * @param string имя базы данных
     * @return Mysql
     */
    public function setDatabaseName($database_name)
    {
        if (!$database_name) {
            throw new Exception(__METHOD__ . ': Не указано имя базы данных');
        }

        $this->database_name = $database_name;

        if (!$this->mysqli->select_db($this->database_name)) {
            throw new Exception(__METHOD__ . ': ' . $this->mysqli->error);
        }

        return $this;
    }

    /**
     * Возвращает имя текущей БД.
     *
     * @param void
     * @return string
     */
    public function getDatabaseName()
    {
        return $this->database_name;
    }

    /**
     * Устанавливает режим поведения при несовпадении типа заполнителя и типа аргумента.
     *
     * @param $value int
     * @return Mysql
     */
    public function setTypeMode($value)
    {
        if (!in_array($value, array(self::MODE_STRICT, self::MODE_TRANSFORM))) {
            throw new Exception(__METHOD__ . ': Указан неизвестный тип режима');
        }

        $this->type_mode = $value;

        return $this;
    }

    /**
     * Устанавливает свойство $this->store_queries, отвечающее за накопление исполненных запросов в
     * хранилище $this->queries.
     *
     * @param bool $value
     * @return Mysql
     */
    public function setStoreQueries($value)
    {
        $this->store_queries = (bool) $value;

        return $this;
    }

    /**
     * Выполняет SQL-запрос.
     * Принимает обязательный параметр - SQL-запрос и, в случае наличия,
     * любое количество аргументов - значения заполнителей.
     *
     * @param string строка SQL-запроса
     * @param mixed аргументы для заполнителей
     * @return bool|Statement false в случае ошибки, в обратном случае объект результата
     * @throws Exception
     */
    public function query()
    {
        if (!func_num_args()) {
            return false;
        }

        $args = func_get_args();

        $query = $this->original_query = array_shift($args);

        $this->query = $this->parse($query, $args);

        $result = $this->mysqli->query($this->query);

        if ($this->store_queries) {
            $this->queries[$this->query] = $this->original_query;
        }

        if ($result === false) {
            throw new Exception(__METHOD__ . ': ' . $this->mysqli->error . '; SQL: ' . $this->query);
        }

        if (is_object($result) && $result instanceof mysqli_result) {
            return new Statement($result);
        }

        return $result;
    }

    /**
     * Поведение аналогично методу self::query(), только метод принимает только два параметра -
     * SQL запрос $query и массив аргументов $arguments, которые и будут заменены на заменители в той
     * последовательности, в которой они представленны в массиве $arguments.
     *
     * @param string
     * @param array
     * @return bool|Mysql_Statement
     */
    public function queryArguments($query, array $arguments=array())
    {
        array_unshift($arguments, $query);

        return call_user_func_array(array($this, 'query'), $arguments);
    }

    /**
     * Обёртка над методом $this->parse().
     * Применяется для случаев, когда SQL-запрос формируется частями.
     *
     * Пример:
     *     $getInstance->prepare('WHERE `name` = "?s" OR `id` IN(?ai)', 'Василий', array(1, 2));
     * Результат:
     *     WHERE `name` = "Василий" OR `id` IN(1, 2)
     *
     * @param string SQL-запрос или его часть
     * @param mixed аргументы заполнителей
     * @return boolean|string
     */
    public function prepare()
    {
        if (!func_num_args()) {
            return false;
        }

        $args = func_get_args();
        $query = array_shift($args);

        return $this->parse($query, $args);
    }

    /**
     * Получает количество рядов, задействованных в предыдущей MySQL-операции.
     * Возвращает количество рядов, задействованных в последнем запросе INSERT, UPDATE или DELETE.
     * Если последним запросом был DELETE без оператора WHERE,
     * все записи таблицы будут удалены, но функция возвратит ноль.
     *
     * @see mysqli_affected_rows
     * @param void
     * @return int
     */
    public function getAffectedRows()
    {
        return $this->mysqli->affected_rows;
    }

    /**
     * Возвращает последний оригинальный SQL-запрос до преобразования.
     *
     * @param void
     * @return string
     */
    public function getOriginalQueryString()
    {
        return $this->original_query;
    }

    /**
     * Возвращает последний выполненный MySQL-запрос (после преобразования).
     *
     * @param void
     * @return string
     */
    public function getQueryString()
    {
        return $this->query;
    }

    /**
     * Возвращает массив со всеми исполненными SQL-запросами в рамках текущего объекта.
     *
     * @param void
     * @return array
     */
    public function getQueries()
    {
        return $this->queries;
    }

    /**
     * Возвращает id, сгенерированный предыдущей операцией INSERT.
     *
     * @param void
     * @return int
     */
    public function getLastInsertId()
    {
        return $this->mysqli->insert_id;
    }

    /**
     * Возвращает оригинальный объект mysqli.
     *
     * @param void
     * @return mysqli
     */
    public function getMysqli()
    {
        return $this->mysqli;
    }

    public function __destruct()
    {
        $this->close();
    }

    /**
     * @param string $server
     * @param string $username
     * @param string $password
     * @param string $port
     * @param string $socket
     * @return void
     */
    private function __construct($server, $user, $password, $port, $socket)
    {
        $this->server   = $server;
        $this->user = $user;
        $this->password = $password;
        $this->port = $port;
        $this->socket = $socket;

        $this->connect();
    }

    /**
     * Устанавливает соеденение с базой данных.
     *
     * @param void
     * @return void
     */
    private function connect()
    {
        if (!is_object($this->mysqli) || !$this->mysqli instanceof mysqli) {
            $this->mysqli = @new mysqli($this->server, $this->user, $this->password, null, $this->port, $this->socket);

            if ($this->mysqli->connect_error) {
                throw new Exception(__METHOD__ . ': ' . $this->mysqli->connect_error);
            }
        }
    }

    /**
     * Закрывает MySQL-соединение.
     *
     * @param void
     * @return Mysql
     */
    private function close()
    {
        if (is_object($this->mysqli) && $this->mysqli instanceof mysqli) {
            @$this->mysqli->close();
        }

        return $this;
    }

    /**
     * Возвращает экранированную строку для placeholder-а поиска LIKE (?S).
     *
     * @param string $var строка в которой необходимо экранировать спец. символы
     * @param string $chars набор символов, которые так же необходимо экранировать.
     *                      По умолчанию экранируются следующие символы: `'"%_`.
     * @return string
     */
    private function escapeLike($var, $chars = "%_")
    {
        $var = str_replace('\\', '\\\\', $var);
        $var = $this->mysqlRealEscapeString($var);

        if ($chars) {
            $var = addCslashes($var, $chars);
        }

        return $var;
    }

    /**
     * Экранирует специальные символы в строке для использования в SQL выражении,
     * используя текущий набор символов соединения.
     *
     * @see mysqli_real_escape_string
     * @param string
     * @return string
     */
    private function mysqlRealEscapeString($value)
    {
        return $this->mysqli->real_escape_string($value);
    }

    /**
     * Возвращает строку описания ошибки при несовпадении типов заполнителей и аргументов.
     *
     * @param string $type тип заполнителя
     * @param mixed $value значение аргумента
     * @param string $original_query оригинальный SQL-запрос
     * @return string
     */
    private function createErrorMessage($type, $value, $original_query)
    {
        return "Попытка указать для заполнителя типа $type значение типа " . gettype($value) . " в шаблоне запроса $original_query";
    }

    /**
     * Парсит запрос $query и подставляет в него аргументы из $args.
     *
     * @param string $query SQL запрос или его часть (в случае парсинга условия в скобках [])
     * @param array $args аргументы заполнителей
     * @param string $original_query "оригинальный", полный SQL-запрос
     * @return string SQL запрос для исполнения
     */
    private function parse($query, array $args, $original_query=null)
    {
        $original_query = $original_query ? $original_query : $query;

        $offset = 0;

        while (($posQM = mb_strpos($query, '?', $offset)) !== false) {
            $offset = $posQM;

            $placeholder_type = mb_substr($query, $posQM + 1, 1);

            // Любые ситуации с нахождением знака вопроса, который не явялется заполнителем.
            if ($placeholder_type == '' || !in_array($placeholder_type, array('i', 'd', 's', 'S', 'n', 'A', 'a', 'f'))) {
                $offset += 1;
                continue;
            }

            if (!$args) {
                throw new Exception(
                    __METHOD__ . ': количество заполнителей в запросе ' . $original_query .
                    ' не соответствует переданному количеству аргументов'
                );
            }

            $value = array_shift($args);

            $is_associative_array = false;

            switch ($placeholder_type) {
                // `LIKE` search escaping
                case 'S':
                    $is_like_escaping = true;

                // Simple string escaping
                // В случае установки MODE_TRANSFORM режима, преобразование происходит согласно правилам php типизации
                // http://php.net/manual/ru/language.types.string.php#language.types.string.casting
                // для bool, null и numeric типа.
                case 's':
                    $value = $this->getValueStringType($value, $original_query);
                    $value = !empty($is_like_escaping) ? $this->escapeLike($value) : $this->mysqlRealEscapeString($value);
                    $query = mb_substr_replace($query, $value, $posQM, 2);
                    $offset += mb_strlen($value);
                    break;

                // Integer
                // В случае установки MODE_TRANSFORM режима, преобразование происходит согласно правилам php типизации
                // http://php.net/manual/ru/language.types.integer.php#language.types.integer.casting
                // для bool, null и string типа.
                case 'i':
                    $value = $this->getValueIntType($value, $original_query);
                    $query = mb_substr_replace($query, $value, $posQM, 2);
                    $offset += mb_strlen($value);
                    break;

                // double
                case 'd':
                    $value = $this->getValueFloatType($value, $original_query);
                    $query = mb_substr_replace($query, $value, $posQM, 2);
                    $offset += mb_strlen($value);
                    break;

                // NULL insert
                case 'n':
                    $value = $this->getValueNullType($value, $original_query);
                    $query = mb_substr_replace($query, $value, $posQM, 2);
                    $offset += mb_strlen($value);
                    break;

                // field or table name
                case 'f':
                    $value = $this->escapeFieldName($value, $original_query);
                    $query = mb_substr_replace($query, $value, $posQM, 2);
                    $offset += mb_strlen($value);
                    break;

                // Парсинг массивов.

                // Associative array
                case 'A':
                    $is_associative_array = true;

                // Simple array
                case 'a':
                    $value = $this->getValueArrayType($value, $original_query);

                    $next_char = mb_substr($query, $posQM + 2, 1);

                    if ($next_char != '' && preg_match('#[sid\[]#u', $next_char, $matches)) {
                        // Парсим выражение вида ?a[?i, "?s", "?s"]
                        if ($next_char == '[' and ($close = mb_strpos($query, ']', $posQM+3)) !== false) {
                            // Выражение между скобками [ и ]
                            $array_parse = mb_substr($query, $posQM+3, $close - ($posQM+3));
                            $array_parse = trim($array_parse);
                            $placeholders = array_map('trim', explode(',', $array_parse));

                            if (count($value) != count($placeholders)) {
                                throw new Exception('Несовпадение количества аргументов и заполнителей в массиве, запрос ' . $original_query);
                            }

                            reset($value);
                            reset($placeholders);

                            $replacements = array();

                            foreach ($placeholders as $placeholder) {
                                list($key, $val) = each($value);
                                $replacements[$key] = $this->parse($placeholder, array($val), $original_query);
                            }

                            if (!empty($is_associative_array)) {
                                foreach ($replacements as $key => $val) {
                                    $values[] = $this->escapeFieldName($key, $original_query) . ' = ' . $val;
                                }

                                $value = implode(',', $values);
                            } else {
                                $value = implode(', ', $replacements);
                            }

                            $query = mb_substr_replace($query, $value, $posQM, 4 + mb_strlen($array_parse));
                            $offset += mb_strlen($value);
                        }
                        // Выражение вида ?ai, ?as, ?ap
                        else if (preg_match('#[sid]#u', $next_char, $matches)) {
                            $sql = '';
                            $parts = array();

                            foreach ($value as $key => $val) {
                                switch ($matches[0]) {
                                    case 's':
                                        $val = $this->getValueStringType($val, $original_query);
                                        $val = $this->mysqlRealEscapeString($val);
                                        break;
                                    case 'i':
                                        $val = $this->getValueIntType($val, $original_query);
                                        break;
                                    case 'd':
                                        $val = $this->getValueFloatType($val, $original_query);
                                        break;
                                }

                                if (!empty($is_associative_array)) {
                                    $parts[] = $this->escapeFieldName($key, $original_query) . ' = "' . $val . '"';
                                } else {
                                    $parts[] = '"' . $val . '"';
                                }
                            }

                            $value = implode(', ', $parts);
                            $value = $value !== '' ? $value : 'NULL';

                            $query = mb_substr_replace($query, $value, $posQM, 3);
                            $offset += mb_strlen($value);
                        }
                    } else {
                        throw new Exception('Попытка воспользоваться заполнителем массива без указания типа данных его элементов');
                    }

                    break;
            }
        }

        return $query;
    }

    /**
     * В зависимости от типа режима возвращает либо строковое значение $value,
     * либо кидает исключение.
     *
     * @param mixed $value
     * @param string $original_query оригинальный SQL запрос
     * @throws Exception
     * @return string
     */
    private function getValueStringType($value, $original_query)
    {
        if (!is_string($value) && $this->type_mode == self::MODE_STRICT) {
            // Если это числовой string, меняем его тип для вывода в тексте исключения его типа.
            if ($this->isInteger($value) || $this->isFloat($value)) {
                $value += 0;
            }

            throw new Exception($this->createErrorMessage('string', $value, $original_query));
        }

        // меняем поведение PHP в отношении приведения bool к string
        if (is_bool($value)) {
            return (string) (int) $value;
        }

        if (!is_string($value) && !(is_numeric($value) || is_null($value))) {
            throw new Exception($this->createErrorMessage('string', $value, $original_query));
        }

        return (string) $value;
    }

    /**
     * В зависимости от типа режима возвращает либо строковое значение числа $value,
     * приведенного к типу int, либо кидает исключение.
     *
     * @param mixed $value
     * @param string $original_query оригинальный SQL запрос
     * @throws Exception
     * @return string
     */
    private function getValueIntType($value, $original_query)
    {
        if ($this->isInteger($value)) {
            return $value;
        }

        switch ($this->type_mode) {
            case self::MODE_TRANSFORM:
                if ($this->isFloat($value) || is_null($value) || is_bool($value)) {
                    return (int) $value;
                }

            case self::MODE_STRICT:
                // Если это числовой string, меняем его тип для вывода в тексте исключения его типа.
                if ($this->isFloat($value)) {
                    $value += 0;
                }
                throw new Exception($this->createErrorMessage('integer', $value, $original_query));
        }
    }

    /**
     * В зависимости от типа режима возвращает либо строковое значение числа $value,
     * приведенного к типу float, либо кидает исключение.
     *
     * Внимание! Разделитель целой и дробной части, возвращаемый float, может не совпадать с разделителем СУБД.
     * Для установки необходимого разделителя дробной части используйте setlocale().
     *
     * @param mixed $value
     * @param string $original_query оригинальный SQL запрос
     * @throws Exception
     * @return string
     */
    private function getValueFloatType($value, $original_query)
    {
        if ($this->isFloat($value)) {
            return $value;
        }

        switch ($this->type_mode) {
            case self::MODE_TRANSFORM:
                if ($this->isInteger($value) || is_null($value) || is_bool($value)) {
                    return (float) $value;
                }

            case self::MODE_STRICT:
                // Если это числовой string, меняем его тип на int для вывода в тексте исключения.
                if ($this->isInteger($value)) {
                    $value += 0;
                }
                throw new Exception($this->createErrorMessage('double', $value, $original_query));
        }
    }

    /**
     * В зависимости от типа режима возвращает либо строковое значение 'NULL',
     * либо кидает исключение.
     *
     * @param mixed $value
     * @param string $original_query оригинальный SQL запрос
     * @throws Exception
     * @return string
     */
    private function getValueNullType($value, $original_query)
    {
        if ($value !== null && $this->type_mode == self::MODE_STRICT) {
            // Если это числовой string, меняем его тип для вывода в тексте исключения его типа.
            if ($this->isInteger($value) || $this->isFloat($value)) {
                $value += 0;
            }

            throw new Exception($this->createErrorMessage('NULL', $value, $original_query));
        }

        return 'NULL';
    }

    /**
     * Всегда генерирует исключение, если $value не является массивом.
     * Первоначально была идея в режиме self::MODE_TRANSFORM приводить к типу array
     * скалярные данные, но на данный момент я считаю это излишним послаблением для клиентов,
     * которые будут использовать данный класс.
     *
     * @param mixed $value
     * @param string $original_query
     * @throws Exception
     * @return array
     */
    private function getValueArrayType($value, $original_query)
    {
        if (!is_array($value)) {
            throw new Exception($this->createErrorMessage('array', $value, $original_query));
        }

        return $value;
    }

    /**
     * Экранирует имя поля таблицы или столбца.
     *
     * @param string $value
     * @return string $value
     */
    private function escapeFieldName($value, $original_query)
    {
        if (!is_string($value)) {
            throw new Exception($this->createErrorMessage('field', $value, $original_query));
        }

        $new_value = '';

        $replace = function($value){
            return '`' . str_replace("`", "``", $value) . '`';
        };

        // Признак обнаружения символа текущей базы данных
        $dot = false;

        if ($values = explode('.', $value)) {
            foreach ($values as $value) {
                if ($value === '') {
                    if (!$dot) {
                        $dot = true;
                        $new_value .= '.';
                    } else {
                        throw new Exception('Два символа `.` идущие подряд в имени столбца или таблицы');
                    }
                } else {
                    $new_value .= $replace($value) . '.';
                }
            }

            return rtrim($new_value, '.');
        } else {
            return $replace($value);
        }
    }

    /**
     * Проверяет, является ли значение целым числом, умещающимся в диапазон PHP_INT_MAX.
     *
     * @param mixed $input
     * @return boolean
     */
    private function isInteger($val)
    {
        if (!is_scalar($val) || is_bool($val)) {
            return false;
        }

        return $this->isFloat($val) ? false : preg_match('~^((?:\+|-)?[0-9]+)$~', $val) === 1;
    }

    /**
     * Проверяет, является ли значение числом с плавающей точкой.
     *
     * @param mixed $input
     * @return boolean
     */
    private function isFloat($val)
    {
        if (!is_scalar($val) || is_bool($val)) {
            return false;
        }

        $type = gettype($val);

        if ($type === "double") {
            return true;
        } else {
            return preg_match("/^([+-]*\\d+)*\\.(\\d+)*$/", $val) === 1;
        }
    }
}

/**
 * Заменяет часть строки string, начинающуюся с символа с порядковым номером start
 * и (необязательной) длиной length, строкой replacement и возвращает результат.
 *
 * @param string $string
 * @param string $replacement
 * @param string $start
 * @param string $length
 * @param string $encoding
 * @return string
 */
if (!function_exists("mb_substr_replace"))
{
    function mb_substr_replace($string, $replacement, $start, $length=null, $encoding=null)
    {
        if ($encoding == null) {
            $encoding = mb_internal_encoding();
        }

        if ($length == null) {
            return mb_substr($string, 0, $start, $encoding) . $replacement;
        } else {
            if ($length < 0) {
                $length = mb_strlen($string, $encoding) - $start + $length;
            }

            return
            mb_substr($string, 0, $start, $encoding) .
            $replacement .
            mb_substr($string, $start + $length, mb_strlen($string, $encoding), $encoding);
        }
    }
}
