diff --git a/claude.md b/claude.md
index ea6dd77..3c05751 100644
--- a/claude.md
+++ b/claude.md
@@ -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.
diff --git a/src/Neuron/DB/Database.php b/src/Neuron/DB/Database.php
index 91d02dd..ba3d4ea 100755
--- a/src/Neuron/DB/Database.php
+++ b/src/Neuron/DB/Database.php
@@ -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 ();
@@ -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
*/
@@ -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 = '' . $txt . '';
+ return;
}
- */
+ $txt = '[' . number_format ((float) $duration, 3) . ' s] ';
$txt .= trim ($sSQL);
- $this->increaseOriginCounter ($origin['file'], $origin['line']);
- //$txt .= '
' . $origin['file'] . ':' . $origin['line'];
-
+ // A logger is a sink the application set itself; it is not gated.
if (isset ($this->logger))
{
$color = 'green';
@@ -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;
diff --git a/src/Neuron/DB/MySQL.php b/src/Neuron/DB/MySQL.php
index 4ee45d6..1183aef 100755
--- a/src/Neuron/DB/MySQL.php
+++ b/src/Neuron/DB/MySQL.php
@@ -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));
}
}
diff --git a/src/Neuron/Exceptions/DbException.php b/src/Neuron/Exceptions/DbException.php
index a51158a..bd5bb7e 100644
--- a/src/Neuron/Exceptions/DbException.php
+++ b/src/Neuron/Exceptions/DbException.php
@@ -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;
/**
@@ -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
*/
diff --git a/tests/DbExceptionTest.php b/tests/DbExceptionTest.php
index d622565..d214210 100644
--- a/tests/DbExceptionTest.php
+++ b/tests/DbExceptionTest.php
@@ -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 ());
+ }
}
diff --git a/tests/DbQueryLogTest.php b/tests/DbQueryLogTest.php
new file mode 100644
index 0000000..c7bda6a
--- /dev/null
+++ b/tests/DbQueryLogTest.php
@@ -0,0 +1,104 @@
+assertFalse (Database::isQueryLogEnabled ());
+ }
+
+ public function testNothingIsStoredWhenTheLogIsOff ()
+ {
+ $db = new LoggingTestDatabase ();
+
+ $db->query ("UPDATE users SET password = 'secret-hash' WHERE id = 1");
+ $db->log ('a comment');
+
+ $this->assertSame (array (), $db->getAllQueries ());
+ $this->assertNull ($db->getLastQuery ());
+ }
+
+ public function testCountersKeepWorkingWhenTheLogIsOff ()
+ {
+ $db = new LoggingTestDatabase ();
+
+ $db->query ('SELECT 1');
+ $db->query ('SELECT 2');
+
+ $this->assertSame (2, $db->getQueryCounter ());
+ $this->assertCount (2, $db->getOriginCounters ());
+ }
+
+ public function testStatementsAreStoredWhenTheLogIsOn ()
+ {
+ Database::setQueryLogEnabled (true);
+ $this->assertTrue (Database::isQueryLogEnabled ());
+
+ $db = new LoggingTestDatabase ();
+ $db->query ('SELECT 1');
+ $db->log ('a comment');
+
+ $queries = $db->getAllQueries ();
+ $this->assertCount (2, $queries);
+ $this->assertStringContainsString ('SELECT 1', $queries[0]);
+ $this->assertStringContainsString ('/* a comment */', $queries[1]);
+ $this->assertSame ($queries[1], $db->getLastQuery ());
+ }
+
+ public function testTurningTheLogOffStopsStoringButKeepsWhatWasLogged ()
+ {
+ Database::setQueryLogEnabled (true);
+ $db = new LoggingTestDatabase ();
+ $db->query ('SELECT 1');
+
+ Database::setQueryLogEnabled (false);
+ $db->query ('SELECT 2');
+
+ $this->assertCount (1, $db->getAllQueries ());
+
+ $db->flushLog ();
+ $this->assertSame (array (), $db->getAllQueries ());
+ $this->assertSame (0, $db->getQueryCounter ());
+ }
+
+ public function testTheLogKeepsOnlyTheMostRecentStatements ()
+ {
+ Database::setQueryLogEnabled (true);
+ $db = new LoggingTestDatabase ();
+
+ $total = Database::QUERY_LOG_LIMIT + 3;
+ for ($i = 1; $i <= $total; $i ++) {
+ $db->query ('SELECT ' . $i);
+ }
+
+ $queries = $db->getAllQueries ();
+ $this->assertSame (5000, Database::QUERY_LOG_LIMIT);
+ $this->assertCount (Database::QUERY_LOG_LIMIT, $queries);
+ $this->assertStringEndsWith ('SELECT 4', $queries[0]);
+ $this->assertStringEndsWith ('SELECT ' . $total, $queries[count ($queries) - 1]);
+ $this->assertSame ($total, $db->getQueryCounter ());
+ }
+
+ public function testALoggerStillReceivesStatementsWhenTheLogIsOff ()
+ {
+ $logger = new RecordingLogger ();
+ $db = new LoggingTestDatabase ();
+ $db->setLogger ($logger);
+
+ $db->query ('SELECT 1');
+
+ $this->assertCount (1, $logger->lines);
+ $this->assertStringContainsString ('SELECT 1', $logger->lines[0]);
+ $this->assertSame (array (), $db->getAllQueries ());
+ }
+}
diff --git a/tests/FailingMySQL.php b/tests/FailingMySQL.php
new file mode 100644
index 0000000..980408e
--- /dev/null
+++ b/tests/FailingMySQL.php
@@ -0,0 +1,32 @@
+attempts ++;
+
+ if ($this->openError) {
+ throw $this->openError;
+ }
+
+ return $this->fakeConnection;
+ }
+}
diff --git a/tests/FakeMysqliConnection.php b/tests/FakeMysqliConnection.php
new file mode 100644
index 0000000..00ffd87
--- /dev/null
+++ b/tests/FakeMysqliConnection.php
@@ -0,0 +1,27 @@
+charsets[] = $charset;
+ return $this->acceptCharset;
+ }
+}
diff --git a/tests/LoggingTestDatabase.php b/tests/LoggingTestDatabase.php
new file mode 100644
index 0000000..35b9842
--- /dev/null
+++ b/tests/LoggingTestDatabase.php
@@ -0,0 +1,17 @@
+query_counter ++;
+ $this->addQueryLog ($sSQL, 0.001);
+ return 0;
+ }
+}
diff --git a/tests/MySQLConnectFailureTest.php b/tests/MySQLConnectFailureTest.php
new file mode 100644
index 0000000..47a67d4
--- /dev/null
+++ b/tests/MySQLConnectFailureTest.php
@@ -0,0 +1,112 @@
+openError = new \RuntimeException (self::DRIVER_ERROR, 1045);
+
+ ob_start ();
+ try {
+ $db->connect ();
+ $thrown = null;
+ } catch (DbException $e) {
+ $thrown = $e;
+ }
+ $output = ob_get_clean ();
+
+ $this->assertSame ('', $output);
+ $this->assertInstanceOf (DbException::class, $thrown);
+ $this->assertSame (DbException::CONNECTION_FAILED, $thrown->getMessage ());
+ $this->assertSame ('Database connection failed.', $thrown->getMessage ());
+ $this->assertTrue ($thrown->isConnectionError ());
+ $this->assertNull ($thrown->getQuery ());
+ }
+
+ public function testOriginalErrorStaysAvailableForLogging ()
+ {
+ $db = new FailingMySQL ();
+ $db->openError = new \RuntimeException (self::DRIVER_ERROR, 1045);
+
+ try {
+ $db->connect ();
+ $this->fail ('Expected a DbException');
+ } catch (DbException $e) {
+ $this->assertSame (1045, $e->getErrorCode ());
+ $this->assertNotNull ($e->getPrevious ());
+ $this->assertSame (self::DRIVER_ERROR, $e->getPrevious ()->getMessage ());
+ $this->assertSame (1045, $e->getPrevious ()->getCode ());
+ $this->assertNotSame ($db->openError, $e->getPrevious ());
+ }
+ }
+
+ public function testErrorsThatAreNotExceptionsAreWrappedToo ()
+ {
+ $db = new FailingMySQL ();
+ $db->openError = new \Error ('Class "mysqli" not found');
+
+ $this->expectException (DbException::class);
+ $this->expectExceptionMessage (DbException::CONNECTION_FAILED);
+ $db->connect ();
+ }
+
+ public function testFailedConnectionIsNotKept ()
+ {
+ $db = new FailingMySQL ();
+ $db->openError = new \RuntimeException (self::DRIVER_ERROR, 1045);
+
+ try {
+ $db->connect ();
+ } catch (DbException $e) {
+ }
+
+ $this->assertNull ($db->getConnection ());
+
+ $db->openError = null;
+ $db->fakeConnection = new FakeMysqliConnection ();
+ $db->connect ();
+
+ $this->assertSame (2, $db->attempts);
+ $this->assertSame ($db->fakeConnection, $db->getConnection ());
+ }
+
+ public function testCharsetIsSetThroughTheDriver ()
+ {
+ $db = new FailingMySQL ();
+ $db->fakeConnection = new FakeMysqliConnection ();
+
+ $db->connect ();
+ $db->connect ();
+
+ // example/config/database.php
+ $this->assertSame (array ('utf8'), $db->fakeConnection->charsets);
+ $this->assertSame (1, $db->attempts);
+ }
+
+ public function testRejectedCharsetIsAConnectionFailure ()
+ {
+ $db = new FailingMySQL ();
+ $db->fakeConnection = new FakeMysqliConnection ();
+ $db->fakeConnection->acceptCharset = false;
+ $db->fakeConnection->error = 'Unknown character set';
+ $db->fakeConnection->errno = 2019;
+
+ try {
+ $db->connect ();
+ $this->fail ('Expected a DbException');
+ } catch (DbException $e) {
+ $this->assertSame (DbException::CONNECTION_FAILED, $e->getMessage ());
+ $this->assertSame (2019, $e->getErrorCode ());
+ $this->assertSame ('Unknown character set', $e->getPrevious ()->getMessage ());
+ $this->assertNull ($db->getConnection ());
+ }
+ }
+}
diff --git a/tests/RecordingLogger.php b/tests/RecordingLogger.php
new file mode 100644
index 0000000..34fa8f4
--- /dev/null
+++ b/tests/RecordingLogger.php
@@ -0,0 +1,19 @@
+lines[] = $string;
+ }
+}