bkdotcom/PHPDebugConsole

View on GitHub
src/Debug/Collector/MySqli.php

Summary

Maintainability
A
2 hrs
Test Coverage
B
86%
<?php

/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2024 Brad Kent
 * @version   v3.0
 */

namespace bdk\Debug\Collector;

use bdk\Debug;
use bdk\Debug\Collector\DatabaseTrait;
use bdk\Debug\Collector\MySqli\ExecuteQueryTrait;
use bdk\Debug\Collector\MySqli\MySqliStmt;
use bdk\Debug\Collector\StatementInfo;
use bdk\PubSub\Event;
use Exception;
use mysqli as mysqliBase;
use RuntimeException;

/**
 * mysqli extended with debugging
 *
 * @phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
 */
class MySqli extends mysqliBase
{
    use DatabaseTrait;
    use ExecuteQueryTrait;

    /** @var bool */
    public $connectionAttempted = false;

    /** @var bool */
    protected $autocommit = true;

    /** @var string */
    protected $icon = 'fa fa-database';

    /** @var list<string> */
    protected $savePoints = array();

    /** @var Debug */
    private $debug;

    /**
     * Constructor
     *
     * @param string $host     host name or IP
     * @param string $username MySQL user name
     * @param string $passwd   password
     * @param string $dbname   default database used when performing queries
     * @param int    $port     port number
     * @param string $socket   socket or named pipe that should be used
     * @param Debug  $debug    (optional) Specify PHPDebugConsole instance
     *                           if not passed, will create MySqli channel on singleton instance
     *                           if root channel is specified, will create a MySqli channel
     *
     * @SuppressWarnings(PHPMD.StaticAccess)
     */
    public function __construct($host = null, $username = null, $passwd = null, $dbname = null, $port = null, $socket = null, Debug $debug = null)
    {
        $params = \func_num_args()
            // @phpcs:ignore SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
            ? array(
                'host' => $host,
                'username' => $username,
                'passwd' => $passwd,
                'dbname' => $dbname,
                'port' => $port,
                'socket' => $socket,
            )
            : array();
        $this->doConstruct($params);
        if (!$debug) {
            $debug = Debug::getChannel('MySqli', array('channelIcon' => $this->icon));
        } elseif ($debug === $debug->rootInstance) {
            $debug = $debug->getChannel('MySqli', array('channelIcon' => $this->icon));
        }
        $this->debug = $debug;
        $debug->eventManager->subscribe(Debug::EVENT_OUTPUT, array($this, 'onDebugOutput'), 1);
        $debug->addPlugin($debug->pluginHighlight);
    }

    /**
     * Turns on or off auto-committing database modification (begin transaction)
     *
     * @param bool $mode Whether to turn on auto-commit or not.
     *
     * @return bool
     */
    #[\ReturnTypeWillChange]
    public function autocommit($mode)
    {
        $this->autocommit = $mode;
        $this->debug->info('autocommit', $mode);
        return parent::autocommit($mode);
    }

    /**
     * Initiates a transaction
     *
     * @param int    $flags A bitmask of MYSQLI_TRANS_START_* constants
     * @param string $name  Savepoint name for the transaction
     *
     * @return bool
     */
    #[\ReturnTypeWillChange]
    public function begin_transaction($flags = 0, $name = null)
    {
        $return = $name === null
            ? parent::begin_transaction($flags)
            : parent::begin_transaction($flags, $name);
        if ($return === false) {
            $this->debug->warn($this->error);
            return $return;
        }
        $this->savePoints = $name
            ? array($name)
            : array();
        $debugArgs = \array_filter(array(
            'begin_transaction',
            $name,
            $this->debug->meta(array(
                'icon' => $this->debug->getCfg('channelIcon', Debug::CONFIG_DEBUG),
            )),
        ));
        \call_user_func_array(array($this->debug, 'info'), $debugArgs);
        return $return;
    }

    /**
     * Commits the current transaction
     *
     * @param int    $flags A bitmask of MYSQLI_TRANS_COR_* constants
     * @param string $name  If provided then COMMIT/name/ is executed.
     *
     * @return bool
     */
    #[\ReturnTypeWillChange]
    public function commit($flags = 0, $name = null)
    {
        $return = $name === null
            ? parent::commit($flags)
            : parent::commit($flags, $name);
        if ($return === false) {
            $this->debug->warn($this->error);
            return $return;
        }
        $this->savePoints = array();
        if ($name !== null) {
            $this->debug->warn('passing $name param to mysqli::commit() does nothing!');
        }
        $this->debug->info('commit', $this->debug->meta(array(
            'icon' => $this->debug->getCfg('channelIcon', Debug::CONFIG_DEBUG),
        )));
        return $return;
    }

    /**
     * {@inheritDoc}
     */
    #[\ReturnTypeWillChange]
    public function multi_query($query)
    {
        return $this->profileCall('multi_query', $query, \func_get_args());
    }

    /**
     * {@inheritDoc}
     */
    #[\ReturnTypeWillChange]
    public function prepare($query)
    {
        return new MySqliStmt($this, $query);
    }

    /**
     * {@inheritDoc}
     */
    #[\ReturnTypeWillChange]
    public function query($query, $resultMode = MYSQLI_STORE_RESULT)
    {
        return $this->profileCall('query', $query, array($query, $resultMode));
    }

    /**
     * {@inheritDoc}
     */
    #[\ReturnTypeWillChange]
    public function real_connect($host = null, $username = null, $passwd = null, $dbname = null, $port = null, $socket = null, $flags = null)
    {
        $this->connectionAttempted = true;
        return parent::real_connect($host, $username, $passwd, $dbname, $port, $socket, (int) $flags);
    }

    /**
     * {@inheritDoc}
     */
    #[\ReturnTypeWillChange]
    public function real_query($query)
    {
        return $this->profileCall('real_query', $query, \func_get_args());
    }

    /**
     * {@inheritDoc}
     */
    #[\ReturnTypeWillChange]
    public function release_savepoint($name)
    {
        $return = parent::release_savepoint($name);
        $index = \array_search($name, $this->savePoints, true);
        if (PHP_VERSION_ID < 70000) {
            $this->debug->warn(
                'mysqli::release_savepoint on PHP < 7.0 just calls %cSAVEPOINT `Sally`%c',
                'font-family: monospace;',
                ''
            );
        }
        if ($return === false) {
            $this->debug->warn($this->error);
            return $return;
        }
        if ($index !== false) {
            unset($this->savePoints[$index]);
            $this->savePoints = \array_values($this->savePoints);
        }
        if (PHP_VERSION_ID < 70000) {
            $this->savePoints[] = $name;
        }
        return $return;
    }

    /**
     * Rolls back current transaction
     *
     * @param int    $flags A bitmask of MYSQLI_TRANS_COR_* constants.
     * @param string $name  If provided then ROLLBACK /name/ is executed.
     *
     * @return bool
     */
    #[\ReturnTypeWillChange]
    public function rollBack($flags = 0, $name = null)
    {
        $return = $name === null
            ? parent::rollback($flags)
            : parent::rollback($flags, $name);
        if ($return === false) {
            $this->debug->warn($this->error);
            return $return;
        }
        $this->savePoints = array();
        if ($name !== null) {
            $this->debug->warn(
                'passing $name param to %cmysqli::rollback()%c does not %cROLLBACK TO name%c as you would expect!',
                'font-family: monospace;',
                '',
                'font-family: monospace;',
                ''
            );
        }
        $this->debug->info('rollBack', $this->debug->meta(array(
            'icon' => $this->debug->getCfg('channelIcon', Debug::CONFIG_DEBUG),
        )));
        return $return;
    }

    /**
     * {@inheritDoc}
     */
    #[\ReturnTypeWillChange]
    public function savepoint($name)
    {
        $return = parent::savepoint($name);
        if ($return === false) {
            $this->debug->warn($this->error);
            return $return;
        }
        $index = \array_search($name, $this->savePoints, true);
        if ($index !== false) {
            \array_splice($this->savePoints, $index, 1);
        }
        $this->savePoints[] = $name;
        $this->debug->info('savepoint', $name);
        return $return;
    }

    /**
     * {@inheritDoc}
     */
    #[\ReturnTypeWillChange]
    public function stmt_init()
    {
        return new MySqliStmt($this, null);
    }

    /**
     * Debug::EVENT_OUTPUT subscriber
     *
     * @param Event $event Event instance
     *
     * @return void
     */
    public function onDebugOutput(Event $event)
    {
        $debug = $event->getSubject();
        $debug->groupSummary(0);
        \set_error_handler(static function ($errno, $errstr) {
            throw new RuntimeException($errstr, $errno);
        }, E_ALL);
        try {
            $debug->groupCollapsed(
                'MySqli info',
                $this->host_info,
                $debug->meta(array(
                    'argsAsParams' => false,
                    'icon' => $this->icon,
                    'level' => 'info',
                ))
            );
            $this->logRuntime($debug);
            $debug->groupEnd(); // groupCollapsed
        } catch (RuntimeException $e) {
            $debug->group('MySqli Error', $debug->meta(array('level' => 'error')));
            $debug->log('Connection Error');
            $debug->groupEnd(); // MySqli Error
            $debug->groupEnd(); // groupCollapsed (opened in try)
        }
        \restore_error_handler();
        $debug->groupEnd(); // groupSummary
    }

    /**
     * Get current database / schema
     *
     * @return string|null
     *
     * @SuppressWarnings(PHPMD.UnusedPrivateMethod) -> called via DatabaseTrait
     */
    private function currentDatabase()
    {
        $result = parent::query('select database() as `database`');
        if ($result instanceof \mysqli_result) {
            $row = $result->fetch_assoc();
            if ($row) {
                return $row['database'];
            }
        }
        return null;
    }

    /**
     * Call mysqli constructor with appropriate params
     *
     * @param array $params host, username, etc
     *
     * @return void
     */
    private function doConstruct($params)
    {
        if (!$params) {
            /*
                Calling the constructor with no parameters is the same as calling mysqli_init().
            */
            parent::__construct();
            return;
        }
        // @phpcs:ignore SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
        $paramsDefault = array(
            'host' => \ini_get('mysqli.default_host'),
            'username' => \ini_get('mysqli.default_user'),
            'passwd' => \ini_get('mysqli.default_pw'),
            'dbname' => '',
            'port' => \ini_get('mysqli.default_port'),
            'socket' => \ini_get('mysqli.default_socket'),
        );
        $params = \array_filter($params);
        $params = \array_merge($paramsDefault, $params);
        $this->connectionAttempted = true;
        parent::__construct(
            $params['host'],
            $params['username'],
            $params['passwd'],
            $params['dbname'],
            $params['port'],
            $params['socket']
        );
    }

    /**
     * Profiles a call to a mysqli method
     *
     * @param string $method PDO method
     * @param string $sql    sql statement
     * @param array  $args   method args
     *
     * @return mixed The result of the call
     * @throws Exception
     */
    private function profileCall($method, $sql, array $args)
    {
        $info = new StatementInfo($sql);
        if ($this->connectionAttempted === false) {
            $info->end(new Exception('Not connected'), null);
            $this->addStatementInfo($info);
            return false;
        }
        if ($method === 'execute_query') {
            $info->setParams($args[1]);
        }
        $return = \call_user_func_array(array('mysqli', $method), $args);
        $exception = !$return
            ? new Exception($this->error, $this->errno)
            : null;
        $affectedRows = $method !== 'multi_query' && $return
            ? $this->affected_rows
            : null;
        $info->end($exception, $affectedRows);
        $this->addStatementInfo($info);
        return $return;
    }

    /**
     * `self::stat()`, but parsed
     *
     * @return array
     *
     * @SuppressWarnings(PHPMD.UnusedPrivateMethod) -> called via DatabaseTrait
     */
    private function serverInfo()
    {
        $matches = array();
        \preg_match_all('#([^:]+): ([a-zA-Z0-9.]+)\s*#', $this->stat(), $matches);
        $serverInfo = \array_map(static function ($val) {
            /** @psalm-suppress InvalidOperand */
            return $val * 1;
        }, \array_combine($matches[1], $matches[2]));
        $serverInfo['Version'] = $this->server_info;
        \ksort($serverInfo);
        return $serverInfo;
    }
}