Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ The primary input validation layer. All user input should be validated through `
### Database Queries (`src/Neuron/DB/Query.php`)
Always use parameterized queries via the `Query` class to prevent SQL injection. Never concatenate user input directly into SQL strings.

### Query log and connection errors (`src/Neuron/DB/Database.php`, `MySQL.php`)
The query log (`getAllQueries()`, `getLastQuery()`) is opt-in: call `Database::setQueryLogEnabled(true)` where the application shows it; it keeps the most recent `Database::QUERY_LOG_LIMIT` (5000) statements. `getQueryCounter()` and `getOriginCounters()` work either way. A connection failure throws a `DbException` with the fixed message `DbException::CONNECTION_FAILED` (`isConnectionError()`); the driver's error is the previous exception, for logging only.

### Testing
Tests are located in `tests/` and use PHPUnit. Database-dependent tests are grouped with `#[Group('database')]` and require a MySQL connection. CI runs tests excluding this group.

Expand Down
82 changes: 56 additions & 26 deletions src/Neuron/DB/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ abstract class Database
protected $affected_rows = 0;
protected $query_counter = 0;

/** The query log keeps at most this many statements (the most recent). */
const QUERY_LOG_LIMIT = 5000;

/** @var bool See setQueryLogEnabled (). */
private static $queryLogEnabled = false;

protected $query_log = array ();

protected $origin_counter = array ();
Expand Down Expand Up @@ -125,7 +131,7 @@ public function rollback ()
}

/**
* Just put a comment in the query log.
* Just put a comment in the query log (when it is enabled).
* Does not connect to database.
* @param $txt
*/
Expand All @@ -134,46 +140,51 @@ public function log ($txt)
$this->addQueryLog ("/* " . $txt . " */");
}

protected function addQueryLog ($sSQL, $duration = null)
/**
* Keep the executed statements (with their bound values) in memory, so
* getAllQueries () and getLastQuery () can return them. Off by default:
* turn it on only where the application shows or inspects the log.
* Applies to every Database instance.
* @param bool $enabled
*/
public static function setQueryLogEnabled ($enabled)
{
/*
if (! (defined ('DEBUG') && DEBUG) && !isset ($_GET['debug']))
{
return;
}
*/
self::$queryLogEnabled = (bool) $enabled;
}

if (count ($this->query_log) > 5000)
{
array_shift ($this->query_log);
}
/**
* @return bool
*/
public static function isQueryLogEnabled ()
{
return self::$queryLogEnabled;
}

$stacktrace = debug_backtrace ();
protected function addQueryLog ($sSQL, $duration = null)
{
$stacktrace = debug_backtrace (DEBUG_BACKTRACE_IGNORE_ARGS, 3);
$origin = $stacktrace[1];

//var_dump ($stacktrace[2]);

if (isset ($stacktrace[2]['class']) && $stacktrace[2]['class'] == 'Neuron\DB\Query')
{
$origin = $stacktrace[2];
}

$txt = '[' . number_format ($duration, 3) . ' s] ';
//$txt .= $origin['class'] . ' ';
// The origin counters hold no statement text, so they are always kept.
if (isset ($origin['file']))
{
$this->increaseOriginCounter ($origin['file'], $origin['line']);
}

// QUery took longer than 1 second?
/*
if ($duration > 0.1)
if (!self::$queryLogEnabled && !isset ($this->logger))
{
$txt = '<span style="color: red; font-weight: bold;">' . $txt . '</span>';
return;
}
*/

$txt = '[' . number_format ((float) $duration, 3) . ' s] ';
$txt .= trim ($sSQL);

$this->increaseOriginCounter ($origin['file'], $origin['line']);
//$txt .= '<br />' . $origin['file'] . ':' . $origin['line'];

// A logger is a sink the application set itself; it is not gated.
if (isset ($this->logger))
{
$color = 'green';
Expand All @@ -185,14 +196,33 @@ protected function addQueryLog ($sSQL, $duration = null)
$this->logger->log ('DB: ' . preg_replace('!\s+!', ' ', str_replace ("\t", " ", str_replace ("\n", "", $txt))), false, $color);
}

if (!self::$queryLogEnabled)
{
return;
}

$this->query_log[] = $txt;

if (count ($this->query_log) > self::QUERY_LOG_LIMIT)
{
array_shift ($this->query_log);
}
}

/**
* @return string|null The most recent logged statement, null when the
* query log is off or empty.
*/
public function getLastQuery ()
{
return $this->query_log[count ($this->query_log) - 1];
$count = count ($this->query_log);
return $count > 0 ? $this->query_log[$count - 1] : null;
}

/**
* @return string[] The logged statements; empty unless the query log was
* turned on with setQueryLogEnabled (true).
*/
public function getAllQueries ()
{
return $this->query_log;
Expand Down
102 changes: 76 additions & 26 deletions src/Neuron/DB/MySQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,85 @@ class MySQL extends Database
private $connection;


/**
* @throws DbException with a fixed message when the connection or its
* charset cannot be set up. The driver's own error is the exception's
* previous; nothing is printed.
*/
public function connect ()
{
if (!isset ($this->connection))
if (isset ($this->connection))
{
Logger::getInstance ()->log ('Connecting database.');

try
{
$this->connection = new MySQLi
(
Config::get ('database.mysql.host'),
Config::get ('database.mysql.username'),
Config::get ('database.mysql.password'),
Config::get ('database.mysql.database')
);

$this->connection->query ('SET names "' . Config::get ('database.mysql.charset') . '"');
//$this->connection->query ("SET time_zone = '+00:00'");
}
catch (Exception $e)
{
echo $e;
}

if (mysqli_connect_errno ())
{
printf ("Connect failed: %s\n", mysqli_connect_error());
exit();
}
return;
}

Logger::getInstance ()->log ('Connecting database.');

try
{
$connection = $this->openConnection ();
$this->applyCharset ($connection, Config::get ('database.mysql.charset'));
}
catch (\Throwable $e)
{
throw DbException::connectionFailed ($e->getMessage (), $e->getCode ());
}

$this->connection = $connection;
}

/**
* Open the connection described by the database.mysql config.
* @return mysqli
* @throws \Throwable when the connection cannot be made.
*/
protected function openConnection ()
{
// Have mysqli throw, whatever the report mode: without it a failed
// connect raises a PHP warning that carries the host and the user.
$driver = new \mysqli_driver ();
$reportMode = $driver->report_mode;
mysqli_report (MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

try
{
$connection = new MySQLi
(
Config::get ('database.mysql.host'),
Config::get ('database.mysql.username'),
Config::get ('database.mysql.password'),
Config::get ('database.mysql.database')
);
}
finally
{
mysqli_report ($reportMode);
}

if ($connection->connect_errno)
{
throw new \RuntimeException ((string) $connection->connect_error, intval ($connection->connect_errno));
}

return $connection;
}

/**
* Set the connection charset through the driver, so escaping uses it too.
* @param mysqli $connection
* @param string|null $charset nothing is set when empty
* @throws \Throwable when the server rejects the charset.
*/
protected function applyCharset ($connection, $charset)
{
if ($charset === null || $charset === '')
{
return;
}

if (!$connection->set_charset ($charset))
{
throw new \RuntimeException ((string) $connection->error, intval ($connection->errno));
}
}

Expand Down
37 changes: 37 additions & 0 deletions src/Neuron/Exceptions/DbException.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ class DbException
/** MySQL error number for a UNIQUE / PRIMARY KEY violation. */
const DUPLICATE_ENTRY = 1062;

/** The message of every connection failure; it never carries driver detail. */
const CONNECTION_FAILED = 'Database connection failed.';

private $query;

private $connectionError = false;

private $mysqlErrorCode;

/**
Expand All @@ -29,6 +34,38 @@ public static function fromMysqliException (\mysqli_sql_exception $e, $query = n
return $ex;
}

/**
* The connection (or its charset) could not be set up. The message is
* fixed; what the driver reported is kept as the previous exception, for
* logging only: it can name the host and the user.
* @param string $driverMessage
* @param int|string $driverCode
* @return DbException
*/
public static function connectionFailed ($driverMessage, $driverCode = 0)
{
$code = is_numeric ($driverCode) ? intval ($driverCode) : 0;

// A new exception rather than the driver's own: its stack trace does
// not hold the arguments of the connect call.
$cause = new \RuntimeException ((string) $driverMessage, $code);

$ex = new self (self::CONNECTION_FAILED, $code, $cause);
$ex->setErrorCode ($code);
$ex->connectionError = true;
return $ex;
}

/**
* True when the connection could not be set up, as opposed to a
* statement that failed.
* @return bool
*/
public function isConnectionError ()
{
return $this->connectionError;
}

/**
* @param string $query
*/
Expand Down
27 changes: 27 additions & 0 deletions tests/DbExceptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,31 @@ public function testFromMysqliExceptionKeepsCodeQueryAndPrevious ()
$this->assertSame ($original, $e->getPrevious ());
$this->assertTrue ($e->isDuplicateEntry ());
}

public function testConnectionFailedHasAFixedMessageAndKeepsTheCause ()
{
$e = DbException::connectionFailed ("Can't connect to MySQL server on 'db.internal'", 2002);

$this->assertSame ('Database connection failed.', $e->getMessage ());
$this->assertSame (DbException::CONNECTION_FAILED, $e->getMessage ());
$this->assertTrue ($e->isConnectionError ());
$this->assertSame (2002, $e->getErrorCode ());
$this->assertSame (2002, $e->getCode ());
$this->assertNull ($e->getQuery ());
$this->assertSame ("Can't connect to MySQL server on 'db.internal'", $e->getPrevious ()->getMessage ());
$this->assertSame (2002, $e->getPrevious ()->getCode ());
}

public function testConnectionFailedAcceptsANonNumericCode ()
{
$e = DbException::connectionFailed ('driver missing', 'HY000');

$this->assertSame (0, $e->getCode ());
$this->assertSame ('Database connection failed.', $e->getMessage ());
}

public function testOtherErrorsAreNotConnectionErrors ()
{
$this->assertFalse ((new DbException ('dup'))->setErrorCode (1062)->isConnectionError ());
}
}
Loading
Loading