diff --git a/README.md b/README.md index 1ba17fe..ba640c9 100644 --- a/README.md +++ b/README.md @@ -3,42 +3,61 @@ Non-blocking MySQLi database access with PHP. Designed to work with [reactphp/react](https://github.com/reactphp/react). +[![Build Status](https://travis-ci.org/dustingraham/react-mysql.svg?branch=master)](https://travis-ci.org/dustingraham/react-mysql) + +## Quickstart + + $db = new \DustinGraham\ReactMysql\Database( + ['localhost', 'apache', 'apache', 'react_mysql_test'] + ); + + $db->statement('SELECT * FROM simple_table WHERE id = :test', [':test' => 2]) + ->then(function(\mysqli_result $result) + { + $rows = $result->fetch_all(MYSQLI_ASSOC); + }); + + $db->shuttingDown = true; + $db->loop->run(); + +Setting `shuttingDown` to true will allow the loop to exit once the query has resolved. ## Working -This __is__ working. But it is nowhere near complete. +This __is__ working. But it is nowhere near complete. Check out the example file +as well as the unit tests for more examples. - $ ./run - Starting loop... - DB Created. + $ ./example + Creating database....done! Run Query: 0 Found rows: 0 Run Query: 1 Found rows: 1 - Current memory usage: 735.117K + Current memory usage: 868.164K Run Query: 2 - Found rows: 0 + Found rows: 1 Run Query: 3 Found rows: 1 Run Query: 4 - Found rows: 1 - Current memory usage: 735.117K + Found rows: 0 + Current memory usage: 868.164K Run Query: 5 Found rows: 0 - Current memory usage: 733.602K - Current memory usage: 733.602K - Current memory usage: 733.602K + Current memory usage: 865.719K + Current memory usage: 865.719K + Current memory usage: 865.719K Loop finished, all timers halted. This won't work out of the box without the database configured. -As of this point, database configuration is hard coded. -Still need to pull out the configs. You will also need to -set up a database with some data to query. Check back later -for more! +You will also need to set up a database with some data to query. -## TODO +## Unit Tests -A lot. +The example and unit tests expect a database called `react_mysql_test` which it +will populate with the proper tables each time it runs. It also expects `localhost` +and a user `apache` with password `apache`. + +## TODO This is not production ready. Still tons to do on the query builder. While I hate to reinvent the wheel, I have not found a lightweight @@ -52,23 +71,20 @@ These are just plans for now. It may change wildly as we develop. Here is an example of what is currently working for the most part. - $loop = React\EventLoop\Factory::create(); - - ConnectionFactory::init($loop, ['db_host', 'db_user', 'db_pass', 'db_name']); - - $db = new \DustinGraham\ReactMysql\Database(); + $db = new \DustinGraham\ReactMysql\Database( + ['localhost', 'apache', 'apache', 'react_mysql_test'] + ); - $db->createCommand("SELECT * FROM `table` WHERE id = :id;", [':id' => $id]) - ->execute()->then( - function($result) + $db->statement('SELECT * FROM simple_table WHERE id = :test', [':test' => 2]) + ->then(function(\mysqli_result $result) { $rows = $result->fetch_all(MYSQLI_ASSOC); - $result->close(); // Do something with $rows. - } - ); + }); + $db->shuttingDown = true; + $db->loop->run(); ### Original Big Picture Plans diff --git a/example b/example new file mode 100755 index 0000000..bbee089 --- /dev/null +++ b/example @@ -0,0 +1,50 @@ +#!/usr/bin/env php +loop->addPeriodicTimer(0.3, function (\React\EventLoop\Timer\TimerInterface $timer) use (&$j) +{ + $memory = memory_get_usage() / 1024; + $formatted = number_format($memory, 3).'K'; + echo "Current memory usage: {$formatted}\n"; + + if ($j++ > 3) $timer->cancel(); +}); + +$i = 0; +$db->loop->addPeriodicTimer(0.1, function (\React\EventLoop\Timer\TimerInterface $timer) use (&$i, $db) +{ + echo "Run Query: $i\n"; + + $db->statement( + 'SELECT * FROM `simple_table` WHERE id = :test', + [':test' => $i] + )->then(function(\mysqli_result $result) + { + $rows = $result->fetch_all(MYSQLI_ASSOC); + echo 'Found rows: '.count($rows).PHP_EOL; + })->done(); + + if ($i++ >= 5) + { + // All queries added. + $db->shuttingDown = true; + $timer->cancel(); + } +}); + +$db->loop->run(); + +echo 'Loop finished, all timers halted.'.PHP_EOL; diff --git a/run b/run deleted file mode 100755 index 767ff39..0000000 --- a/run +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env php -addPeriodicTimer(0.3, function ($timer) use (&$j) -{ - $memory = memory_get_usage() / 1024; - $formatted = number_format($memory, 3).'K'; - echo "Current memory usage: {$formatted}\n"; - - if ($j++ > 3) $timer->cancel(); -}); - -$i = 0; -$loop->addPeriodicTimer(0.1, function ($timer) use (&$i, $db) -{ - echo "Run Query: $i\n"; - - $db->createCommand( - 'SELECT * FROM `simple_table` WHERE id = :test', - [':test' => $i] - )->execute()->then( - function($result) - { - if (is_null($result)) - { - echo 'Null result...'.PHP_EOL.PHP_EOL; - exit; - } - - $rows = $result->fetch_all(MYSQLI_ASSOC); - $result->close(); - - echo 'Found rows: '.count($rows).PHP_EOL; - } - ); - - if ($i++ >= 5) $timer->cancel(); -}); - -$loop->run(); - -echo 'Loop finished, all timers halted.'.PHP_EOL; diff --git a/src/Command.php b/src/Command.php index c1fcb14..c625b5d 100644 --- a/src/Command.php +++ b/src/Command.php @@ -2,11 +2,6 @@ class Command { - /** - * @var Database the command is associated with. - */ - public $db; - /** * @var string */ @@ -18,16 +13,18 @@ class Command protected $params = []; /** + * TODO: Find all of these + * * @var array */ protected $reserved_words = [ 'NOW()', ]; - public function __construct(Database $database, $sql = null) + public function __construct($sql = null, $params = null) { - $this->db = $database; $this->sql = $sql; + $this->bind($params); } /** @@ -39,10 +36,12 @@ public function bind($key, $value = null) { if (is_array($key)) { - // TODO: Is this cludgy? - $this->bindValues($key); + foreach ($key as $k => $v) + { + $this->params[$k] = $v; + } } - else + else if (!is_null($key)) { $this->params[$key] = $value; } @@ -50,33 +49,15 @@ public function bind($key, $value = null) return $this; } - /** - * @param $params - * @return $this - */ - public function bindValues($params) - { - foreach ($params as $k => $v) - { - $this->params[$k] = $v; - } - - return $this; - } - /** * @param Connection $connection * @return string */ public function getPreparedQuery(Connection $connection) { - $quotedSql = $this->quoteIntoSql($connection); - - return $quotedSql; + return $this->quoteIntoSql($connection); } - // TODO: Find all of these... - /** * TODO: This is exactly what I don't want to do. "Roll my own" SQL handler. * However, the requirements for this package have led to this point for now. @@ -111,14 +92,4 @@ protected function quoteIntoSql(Connection $connection) return strtr($quotedSql, $quotedParams); } - - /** - * @return \React\Promise\Promise - */ - public function execute() - { - $thing = $this->db->executeCommand($this); - - return $thing; - } } diff --git a/src/Connection.php b/src/Connection.php index a487491..2cad272 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -1,35 +1,28 @@ mysqli = $mysqli; - $this->loop = $loop; + parent::__construct($host, $username, $passwd, $dbname, $port, $socket); + + $this->id = self::$nextId++; } /** @@ -40,84 +33,6 @@ public function __construct(\mysqli $mysqli, LoopInterface $loop) */ public function escape($string) { - return $this->mysqli->real_escape_string($string); - } - - /** - * Close the mysqli connection. - */ - public function close() - { - $this->mysqli->close(); - } - - public function execute(Command $command) - { - if ($this->currentQuery) - { - throw new \Exception('Another query is already pending for this connection.'); - } - - $this->currentQuery = $command->getPreparedQuery($this); - - $status = $this->mysqli->query($this->currentQuery, MYSQLI_ASYNC); - if ($status === false) - { - throw new \Exception($this->mysqli->error); - } - - $deferred = new Deferred(); - - $this->loop->addPeriodicTimer( - $this->pollInterval, - function (TimerInterface $timer) use ($deferred) - { - $reads = $errors = $rejects = [$this->mysqli]; - - // Non-blocking requires a zero wait time. - $this->mysqli->poll($reads, $errors, $rejects, 0); - - $read = in_array($this->mysqli, $reads, true); - $error = in_array($this->mysqli, $errors, true); - $reject = in_array($this->mysqli, $rejects, true); - - if ($read) - { - $result = $this->mysqli->reap_async_query(); - if ($result === false) - { - $deferred->reject($this->mysqli->error); - } - else - { - // Success!! - $deferred->resolve($result); - } - } - else - { - if ($error) - { - $deferred->reject($this->mysqli->error); - } - else - { - if ($reject) - { - $deferred->reject($this->mysqli->error); - } - } - } - - // If poll yielded something for this connection, we're done! - if ($read || $error || $reject) - { - $this->currentQuery = false; - $timer->cancel(); - } - } - ); - - return $deferred->promise(); + return $this->real_escape_string($string); } } diff --git a/src/ConnectionFactory.php b/src/ConnectionFactory.php index 856bd12..2fe26b9 100644 --- a/src/ConnectionFactory.php +++ b/src/ConnectionFactory.php @@ -1,46 +1,43 @@ waiting = new \SplQueue(); } - /** - * We use a promise in case all connections are busy. - * - * @return \React\Promise\Promise - */ - public function getConnection() + public function withConnection($cb) { // First check idle connections. if ($this->available->count() > 0) { $connection = $this->available->dequeue(); - return \React\Promise\resolve($connection); + $cb($connection); + + return; } // Check if we have max connections if ($this->pool->count() >= $this->maxConnections) { - $deferred = new Deferred(); - $this->waiting->enqueue($deferred); - - return $deferred->promise(); + $this->waiting->enqueue($cb); } // Otherwise, create a new connection @@ -62,7 +54,7 @@ public function getConnection() $this->pool->attach($connection); - return \React\Promise\resolve($connection); + $cb($connection); } /** @@ -74,7 +66,11 @@ public function releaseConnection(Connection $connection) // If we have any promises waiting for the connection, pass it along. if ($this->waiting->count() > 0) { - $this->waiting->dequeue()->resolve($connection); + $cb = $this->waiting->dequeue(); + + $cb($connection); + + return; } // Otherwise, move it to the idle queue. diff --git a/src/Database.php b/src/Database.php index 44d989c..6ed3f82 100644 --- a/src/Database.php +++ b/src/Database.php @@ -1,5 +1,8 @@ pool = new ConnectionPool(); - } + /** + * @var LoopInterface + */ + public $loop; /** - * @param string|null $sql - * @param array $params - * @return Command + * @var float */ - public function createCommand($sql = null, $params = []) + protected $pollInterval = 0.01; + + public function __construct($credentials = null) { - $command = new Command($this, $sql); + if (!is_null($credentials)) + { + ConnectionFactory::init($credentials); + } + + $this->loop = Factory::create(); + $this->initLoop(); - return $command->bindValues($params); + $this->pool = new ConnectionPool(); } - /** - * @param Command $command - * @return \React\Promise\Promise - */ - public function executeCommand(Command $command) + public function statement($sql, $params = null) { + $command = new Command($sql, $params); + $deferred = new Deferred(); - $this->pool->getConnection() - ->then(function (Connection $connection) use ($command, $deferred) - { - // Connection was retrieved from the pool. Execute the command. - $connection->execute($command) - ->then(function (\mysqli_result $result) use ($deferred) - { - // We must resolve first so that the result can be closed. - $deferred->resolve($result); - - // Doesn't hurt to close it again. - $result->close(); - }) - ->otherwise(function ($reason) use ($deferred) - { - // If the connection execution fails, pass the failure back to the command. - $deferred->reject($reason); - }) - ->always(function () use ($connection) - { - // Ensure we always return the connection to the pool. - $this->pool->releaseConnection($connection); - }); - }); + $this->pool->withConnection(function (Connection $connection) use ($command, $deferred) + { + $sql = $command->getPreparedQuery($connection); + + $connection->query($sql, MYSQLI_ASYNC); + + $this->conns[$connection->id] = [ + 'mysqli' => $connection, + 'deferred' => $deferred, + ]; + }); return $deferred->promise(); } - /** - * @return ConnectionPool - */ - public function getPool() + public function initLoop() { - return $this->pool; + $this->loop->addPeriodicTimer( + $this->pollInterval, + [$this, 'loopTick'] + ); + } + + public $conns = []; + + public $shuttingDown = false; + + public function loopTick(TimerInterface $timer) + { + if (count($this->conns) == 0) + { + // If we are shutting down, and have nothing to check, kill the timer. + if ($this->shuttingDown) + { + // TODO: Possible race condition if shutdown also queues queries, such as a final save. + // This could be prematurely cancelled. + $timer->cancel(); + } + + // Nothing in the queue. + return; + } + + $reads = $errors = $rejects = []; + foreach ($this->conns as $conn) + { + $reads[] = $conn['mysqli']; + } + + // Returns immediately, the non-blocking magic! + if (mysqli_poll($reads, $errors, $rejects, 0) < 1) return; + + /** @var Connection $read */ + foreach ($reads as $read) + { + /** @var Deferred $deferred */ + $deferred = $this->conns[$read->id]['deferred']; + $result = $read->reap_async_query(); + if ($result !== false) + { + $deferred->resolve($result); + + // If userland code has already freed the result, this will throw a warning. + // No need to throw a warning here... + // If you know how to check if the result has already been freed, please PR! + @$result->free(); + } + else + { + $deferred->reject($read->error); + } + + // Release the connection + $this->pool->releaseConnection($read); + + unset($this->conns[$read->id]); + } + + // Check error pile. + // Current understanding is that this would only happen if the connection + // was closed, or not opened correctly. + foreach ($errors as $error) + { + $this->pool->releaseConnection($error); + unset($this->conns[$error->id]); + + throw new \Exception('Unexpected mysqli_poll $error.'); + } + + // Check rejection pile. + // Current understanding is that this would only happen if we passed a + // connection that was already reaped. But... maybe not. + foreach ($rejects as $reject) + { + $this->pool->releaseConnection($reject); + unset($this->conns[$reject->id]); + + throw new \Exception('Unexpected mysqli_poll $reject.'); + } + + // Duplicated check to avoid one extra tick! + // If we are shutting down, cancel timer once connections finish. + if ($this->shuttingDown && count($this->conns) == 0) + { + $timer->cancel(); + } } } diff --git a/tests/CommandTest.php b/tests/CommandTest.php new file mode 100644 index 0000000..a207584 --- /dev/null +++ b/tests/CommandTest.php @@ -0,0 +1,104 @@ + null, + ':name' => 'John\'s Name', + ':num' => 7, + ':datetime' => 'NOW()', + ]); + + $connection = ConnectionFactory::createConnection(); + + $query = $command->getPreparedQuery($connection); + + $this->assertStringEqualsIgnoreSpacing( + "INSERT INTO simple_table ( `id`, `name`, `value`, `created_at` ) VALUES ( NULL, 'John\'s Name', 7, NOW() );", + $query + ); + } + + public function testAssertStrings() + { + $this->assertStringEqualsIgnoreSpacing('yes no ', 'yes no'); + } + + public function testSingleBind() + { + $command = new Command(" + SELECT * FROM simple_table WHERE id = :id + "); + + $command->bind(':id', 1); + + $connection = ConnectionFactory::createConnection(); + + $command->getPreparedQuery($connection); + } + + public function testParameterReplacing() + { + $command = new Command; + $command->sql = 'SELECT * FROM simple_table WHERE id = :id'; + $command->bind(':id', 2); + + $connection = ConnectionFactory::createConnection(); + + $query = $command->getPreparedQuery($connection); + + $this->assertStringEqualsIgnoreSpacing( + 'SELECT * FROM simple_table WHERE id = 2', + $query + ); + } + + /** + * TODO: This test is still todo. + * + * @throws \Exception + */ + public function testParamCounting() + { + // Note: Used a comma rather than => so it was failing. + // param count would detect this sooner. + + // Intentionally bad parameters to ensure check. + $badParams = [':test', 1,]; + // The programmer's intent was: + // $goodParams = [ ':test' => 1, ] + + $command = new Command( + 'SELECT * FROM simple_table WHERE id = :test', + $badParams + ); + + $connection = ConnectionFactory::createConnection(); + + $query = $command->getPreparedQuery($connection); + + // TODO: Here is the bad result, :test should have been 1 + // TODO: GetPreparedQuery should error on param mismatch + $this->assertStringEqualsIgnoreSpacing( + 'SELECT * FROM simple_table WHERE id = :test', + $query + ); + } +} diff --git a/tests/ConnectionFactoryTest.php b/tests/ConnectionFactoryTest.php new file mode 100644 index 0000000..38bdd35 --- /dev/null +++ b/tests/ConnectionFactoryTest.php @@ -0,0 +1,31 @@ +loops++; + if ($this->loops > 200) + { + throw new \Exception('time out failure'); + } + + parent::loopTick($timer); + } +} diff --git a/tests/DatabaseTest.php b/tests/DatabaseTest.php index ed12dfd..d546de6 100644 --- a/tests/DatabaseTest.php +++ b/tests/DatabaseTest.php @@ -1,24 +1,10 @@ createCommand(); - - $this->assertInstanceOf(Command::class, $command); - - $command->bindValues([]); - } - public function testMysqliConnection() { $c = $this->getMysqliConnection(); @@ -39,234 +25,8 @@ public function testMysqliConnection() $this->assertEquals('00000', $c->sqlstate); } - public function testMysqliSynchronous() - { - $c = $this->getMysqliConnection(); - - $result = $c->query('SELECT * FROM simple_table;'); - $this->assertEquals(3, $result->num_rows); - - $tempTableName = 'temptable123'; - $c->query('CREATE TEMPORARY TABLE ' . $tempTableName . ' LIKE simple_table;'); - $result = $c->query('SELECT * FROM ' . $tempTableName); - $this->assertEquals(0, $result->num_rows); - - $stmt = $c->prepare('INSERT INTO ' . $tempTableName . ' (`id`, `name`) VALUES (?, ?)'); - - $id = null; - $name = 'john'; - - $stmt->bind_param('is', $id, $name); - - $stmt->execute(); - $this->assertEquals(1, $stmt->affected_rows, 'Did not insert the row.'); - $stmt->close(); - } - - public function testMysqliAsynchronous() - { - $c = $this->getMysqliConnection(); - - $c->query('SELECT * FROM simple_table;', MYSQLI_ASYNC); - - $result = $c->reap_async_query(); - $this->assertEquals(3, $result->num_rows); - } - - public function testCreateCommandGetPromise() - { - $db = new Database(); - - $cmd = $db->createCommand(); - - $cmd->sql = 'SELECT * FROM simple_table WHERE id = :id'; - $cmd->bind(':id', 1); - - $promise = $cmd->execute(); - $this->assertInstanceOf(Promise::class, $promise); - - //// - - $promise = $db->createCommand( - 'SELECT * FROM simple_table WHERE id = :test', - [ - ':test', - 1, - ] - )->execute(); - $this->assertInstanceOf(Promise::class, $promise); - } - - // TODO: This test is still todo. - public function testParamCounting() + public function testForCoverage() { - // Note: Used a comma rather than => so it was failing. - // param count would detect this sooner. - - // Intentionally bad parameters to ensure check. - $badParams = [':test', 1,]; - // The programmer's intent was: - // $goodParams = [ ':test' => 1, ] - - $db = new Database(); - $command = $db->createCommand( - 'SELECT * FROM simple_table WHERE id = :test', - $badParams - ); - - $connection = ConnectionFactory::createConnection(); - $query = $command->getPreparedQuery($connection); - - // TODO: Here is the bad result, :test should have been 1 - // TODO: GetPreparedQuery should error on param mismatch - $this->assertEquals( - $query, - 'SELECT * FROM simple_table WHERE id = :test' - ); - - $connection->close(); - } - - public function testCommandResolvedResults() - { - $rowCount = false; - $failedReason = false; - - $db = new Database(); - - $db->createCommand( - 'SELECT * FROM simple_table WHERE id = :test', - [':test' => 1,] - ) - ->execute() - ->then(function (\mysqli_result $results) use (&$rowCount) - { - $rows = $results->fetch_all(MYSQLI_ASSOC); - $rowCount = count($rows); - }) - ->otherwise(function ($reason) use (&$failedReason) - { - $failedReason = $reason; - }); - - ConnectionFactory::$loop->run(); - - $this->assertFalse($failedReason); - - $this->assertEquals(1, $rowCount); - } - - public function testAssertStrings() - { - $this->assertStringEqualsIgnoreSpacing('yes no', 'yes no'); - } - - public function testSimpleCommandParameterBinding() - { - $db = new Database(); - $cmd = $db->createCommand(); - $cmd->sql = 'SELECT * FROM simple_table WHERE id = :id'; - $cmd->bind(':id', 1); - - $connection = ConnectionFactory::createConnection(); - $query = $cmd->getPreparedQuery($connection); - - $this->assertEquals('SELECT * FROM simple_table WHERE id = 1', $query); - } - - public function testComplexCommandParameterBinding() - { - $db = new Database(); - $cmd = $db->createCommand(); - $cmd->sql = " - INSERT INTO simple_table ( - `id`, - `name`, - `value`, - `created_at` - ) VALUES ( - :id, - :name, - :num, - :datetime - ); - "; - - $cmd->bind([ - ':id' => null, - ':name' => 'John Cash', - ':num' => 7, - ':datetime' => 'NOW()', - ]); - - $connection = ConnectionFactory::createConnection(); - - $query = $cmd->getPreparedQuery($connection); - - $this->assertStringEqualsIgnoreSpacing( - "INSERT INTO simple_table ( `id`, `name`, `value`, `created_at` ) VALUES ( NULL, 'John Cash', 7, NOW() );", - $query - ); - - $connection->close(); - } - - public function testBadQuery() - { - $db = new Database(); - - $failedReason = null; - $db->createCommand('SELECT * FROM `nonexistant_table`;') - ->execute() - ->otherwise(function ($reason) use (&$failedReason) - { - $failedReason = $reason; - }); - - ConnectionFactory::$loop->run(); - - $this->assertNotNull($failedReason); - } - - public function testPool() - { - $db = new Database(); - $connection = null; - $db->getPool()->getConnection() - ->then(function (Connection $conn) use (&$connection, $db) - { - $connection = $conn; - - // Send it back from whence it came. - $db->getPool()->releaseConnection($conn); - }); - - $sameConnection = false; - $db->getPool()->getConnection() - ->then(function (Connection $conn) use ($connection, &$sameConnection, $db) - { - // Did we get the same one again? - $sameConnection = $conn === $connection; - - $db->getPool()->releaseConnection($conn); - }); - - $this->assertTrue($sameConnection, 'Ensure it is the same exact connection.'); - - // This will cause code coverage to cover the getConnection and releaseConnection - // parts that deal with excess of 100 connections. - $promises = []; - for ($i = 0; $i < 120; $i++) - { - // Pool has up to 100... so pull them all! - $promises[] = $db->getPool()->getConnection(); - } - foreach ($promises as $promise) - { - $promise->then(function (Connection $conn) use ($db) - { - $db->getPool()->releaseConnection($conn); - }); - } + new Database($this->getCredentials()); } } diff --git a/tests/PoolTest.php b/tests/PoolTest.php new file mode 100644 index 0000000..0d9311d --- /dev/null +++ b/tests/PoolTest.php @@ -0,0 +1,63 @@ +getDatabase(); + + // Spin up 120 queries. + for ($i = 0; $i < 120; $i++) + { + $sql = 'SELECT * FROM simple_table WHERE id = ' . $i; + $db->statement($sql)->then(function (\mysqli_result $result) + { + $result->free(); + })->done(); + } + + $db->shuttingDown = true; + $db->loop->run(); + } + + public function testSameConnectionIds() + { + $pool = new ConnectionPool(); + + $id = null; + $pool->withConnection(function ($connection) use ($pool, &$id) + { + $id = $connection->id; + $pool->releaseConnection($connection); + }); + + $pool->withConnection(function ($connection) use ($pool, &$id) + { + $this->assertEquals($id, $connection->id); + + $pool->releaseConnection($connection); + }); + } + + public function testIdenticalConnections() + { + $pool = new ConnectionPool(); + + $connection = null; + + $pool->withConnection(function ($conn) use ($pool, &$connection) + { + $connection = $conn; + $pool->releaseConnection($conn); + }); + + $pool->withConnection(function ($conn) use ($pool, $connection) + { + $this->assertSame($connection, $conn); + + $pool->releaseConnection($conn); + }); + } +} diff --git a/tests/RebuildTest.php b/tests/RebuildTest.php new file mode 100644 index 0000000..9d2e58d --- /dev/null +++ b/tests/RebuildTest.php @@ -0,0 +1,348 @@ +assertTrue(true); + } + + public function testWithClasses() + { + $db = $this->getDatabase(); + + for ($loops = 0; $loops < 3; $loops++) + { + for ($i = 0; $i < 3; $i++) + { + $sql = 'SELECT * FROM simple_table WHERE id = ' . $i; + //$sql = 'SELECT SLEEP(0.1);'; + $db->statement($sql)->then(function (\mysqli_result $result) + { + $rows = $result->fetch_all(MYSQLI_ASSOC); + $this->assertLessThanOrEqual(1, count($rows)); + + //$rowCount = count($rows); + //echo $rowCount; + })->done(); + } + + while (count($db->conns)) + { + usleep(1000); + $db->loop->tick(); + } + } + } + + public function testShutdownWithNothing() + { + $db = $this->getDatabase(); + + $db->shuttingDown = true; + $db->loop->run(); + } + + public function testWithSleepFail() + { + $db = $this->getDatabase(); + + $errorCount = 0; + + $queries = [ + 'SELECT * FROM simple_table WHERE id = 1', + 'SELECT foo FROM', + 'SELECT SLEEP(0.2);', + 'SELECT foo FROM', + 'SELECT SLEEP(0.3);', + 'SELECT foo FROM', + 'SELECT SLEEP(0.1);', + ]; + foreach ($queries as $sql) + { + $db->statement($sql)->then(function (\mysqli_result $result) + { + $rows = $result->fetch_all(MYSQLI_ASSOC); + $this->assertCount(1, $rows); + }) + ->otherwise(function ($error) use (&$errorCount) + { + $errorCount++; + })->done(); + } + + $db->shuttingDown = true; + $db->loop->run(); + + $this->assertSame(3, $errorCount); + } + + public function testSimpleBind() + { + $db = $this->getDatabase(); + + $db->statement('SELECT * FROM simple_table WHERE id = :test', [':test' => 2]) + ->then(function (\mysqli_result $result) + { + $this->assertCount(1, $result->fetch_all(MYSQLI_ASSOC)); + }) + ->done(); + + $db->shuttingDown = true; + $db->loop->run(); + } + + public function testFreeResult() + { + $db = $this->getDatabase(); + + $db->statement('SELECT * FROM simple_table WHERE id = :test', [':test' => 2]) + ->then(function (\mysqli_result $result) + { + $this->assertCount(1, $result->fetch_all(MYSQLI_ASSOC)); + + // Ensure warning is not thrown. + $result->free(); + }) + ->done(); + + $db->shuttingDown = true; + $db->loop->run(); + } + + public function testBadQuery() + { + $db = $this->getDatabase(); + + $errorTriggered = false; + $db->statement('SELECT foo FROM') + ->then(function (\mysqli_result $result) + { + $this->fail(); + }) + ->otherwise(function ($error) use (&$errorTriggered) + { + $errorTriggered = !!$error; + }) + ->done(); + + $db->shuttingDown = true; + $db->loop->run(); + + $this->assertTrue($errorTriggered, 'Error was sent to otherwise callback.'); + } + + public function testUnhandledBadQuery() + { + $db = $this->getDatabase(); + + $db->statement('SELECT foo FROM') + ->then(function (\mysqli_result $result) + { + $this->fail(); + }) + ->done(); + + $this->setExpectedException(UnhandledRejectionException::class); + + $db->shuttingDown = true; + $db->loop->run(); + } + + /** + * Works Brilliantly + */ + public function disabled_testTheConcept() + { + echo PHP_EOL; + $pool = []; + + for ($i = 0; $i < 0; $i++) + { + $mysqli = $this->getNewMysqliConnection(); + $mysqli_id = $mysqli->thread_id; + $mysqli_my = $mysqli->countId; + + $sql = 'SELECT SLEEP(' . $i . ');'; + + $mysqli->query($sql, MYSQLI_ASYNC); + + $deferred = new Deferred(); + + $pool[$mysqli->thread_id] = [ + 'mysqli' => $mysqli, + 'deferred' => $deferred, + ]; + + $deferred->promise()->then(function (\mysqli_result $result) use (&$rowCount) + { + $rows = $result->fetch_all(MYSQLI_ASSOC); + $rowCount = count($rows); + + echo $rowCount; + }); + } + + for ($i = 0; $i < 3; $i++) + { + $mysqli = $this->getNewMysqliConnection(); + $mysqli_id = $mysqli->thread_id; + + $sql = 'SELECT foo FROM'; + + $mysqli->query($sql, MYSQLI_ASYNC); + + $deferred = new Deferred(); + + $pool[$mysqli->thread_id] = [ + 'mysqli' => $mysqli, + 'deferred' => $deferred, + ]; + + $deferred->promise()->then(function (\mysqli_result $result) use (&$rowCount) + { + echo 'M'; + $rows = $result->fetch_all(MYSQLI_ASSOC); + $rowCount = count($rows); + + echo $rowCount; + + $result->close(); + }); + } + + for ($i = 0; $i < 6; $i++) + { + $mysqli = $this->getNewMysqliConnection(); + $mysqli_id = $mysqli->thread_id; + + $sql = 'SELECT * FROM simple_table WHERE id = ' . $i; + + $mysqli->query($sql, MYSQLI_ASYNC); + + $deferred = new Deferred(); + + $pool[$mysqli->thread_id] = [ + 'mysqli' => $mysqli, + 'deferred' => $deferred, + ]; + + $deferred->promise()->then(function (\mysqli_result $result) use (&$rowCount) + { + + $rows = $result->fetch_all(MYSQLI_ASSOC); + $rowCount = count($rows); + + echo $rowCount; + + $result->close(); + }); + } + + $loop = Factory::create(); + + $loop->addPeriodicTimer(0.01, function ($timer) use (&$pool) + { + $reads = []; + foreach ($pool as $p) + { + $reads[] = $p['mysqli']; + } + + if (count($reads) < 1) return; + + if (mysqli_poll($reads, $errors = [], $rejects = [], 0) < 1) return; + + echo '(' . count($reads) . '/' . count($errors) . '/' . count($rejects) . ')'; + + /** @var \mysqli $read */ + foreach ($reads as $read) + { + //echo '{'.$read->thread_id.'}'; + $deferred = $pool[$read->thread_id]['deferred']; + $result = $read->reap_async_query(); + if ($result === false) + { + echo 'W'; + } + $deferred->resolve($result); + + unset($pool[$read->thread_id]); + } + + foreach ($errors as $error) + { + echo 'A'; + unset($pool[$error->thread_id]); + } + + foreach ($rejects as $reject) + { + echo 'B'; + unset($pool[$reject->thread_id]); + } + + if (count($pool) == 0) + { + $timer->cancel(); + } + }); + + $loop->run(); + + //$this->assertEquals(1, $rowCount); + + //$this->assertEquals($mysqli_id, $mysqli->thread_id); + } + + + public function XtestExtendedAssert() + { + foreach ([ + [ + 'a b', + 'a c', + ], + [ + 'alpha beta', + 'alpha delta', + ], + [ + 'ab', + 'a b', + ], + [ + ' a bc', + ' abc', + ], + ] as $test) + { + $this->assertStringNotEqualsIgnoreSpacing($test[0], $test[1]); + } + + foreach ([ + [ + // variable internal spacing + 'a b', + 'a b', + ], + [ + // variable spacing, longer text, more instances + 'alpha beta delta gamma', + 'alpha beta delta gamma', + ], + [ + // Trailing and Leading spaces. + ' a b c', + 'a b c ', + ], + ] as $test) + { + $this->assertStringEqualsIgnoreSpacing($test[0], $test[1]); + } + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 45f2651..952d143 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,10 +1,71 @@ initDatabase(); + + ConnectionFactory::init( + $this->getCredentials() + ); + } + + protected function initDatabase() + { + if (!self::$initialized) + { + // While this package is focused on mysqli async, we can use + // PDO to initialize the database structure efficiently. + $this->getPdoConnection() + ->exec(file_get_contents(__DIR__ . '/sql.sql')); + + self::$initialized = true; + } + } + + protected function getPdoConnection() + { + if (is_null(self::$pdo)) + { + list($host, $user, $pass, $name) = $this->getCredentials(); + $dsn = 'mysql:host=' . $host . ';dbname=' . $name; + self::$pdo = new \PDO($dsn, $user, $pass); + } + + return self::$pdo; + } + + protected function getCredentials() + { + $host = getenv('DB_HOST') !== false ? getenv('DB_HOST') : 'localhost'; + $user = getenv('DB_USER') !== false ? getenv('DB_USER') : 'apache'; + $pass = getenv('DB_PASS') !== false ? getenv('DB_PASS') : 'apache'; + $name = getenv('DB_NAME') !== false ? getenv('DB_NAME') : 'react_mysql_test'; + + return [ + $host, + $user, + $pass, + $name, + ]; } public function assertStringEqualsIgnoreSpacing($expected, $actual, $message = '', $delta = 0.0, $maxDepth = 10, $canonicalize = false, $ignoreCase = false) @@ -22,4 +83,31 @@ public function assertStringNotEqualsIgnoreSpacing($expected, $actual, $message $this->assertNotEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase); } + + protected function getDatabase() + { + return new DatabaseMock(); + } + + /** + * Note, do not close the connection. It is reused throughout the tests. + * + * @return \mysqli + */ + protected function getMysqliConnection() + { + if (is_null(self::$mysqli)) + { + self::$mysqli = $this->getNewMysqliConnection(); + } + + return self::$mysqli; + } + + protected function getNewMysqliConnection() + { + list($host, $user, $pass, $name) = $this->getCredentials(); + + return new \mysqli($host, $user, $pass, $name); + } } diff --git a/tests/TestCaseDatabase.php b/tests/TestCaseDatabase.php deleted file mode 100644 index a407f1d..0000000 --- a/tests/TestCaseDatabase.php +++ /dev/null @@ -1,88 +0,0 @@ -getCredentials() - ); - - $this->initDatabase(); - } - - protected function getCredentials() - { - $host = getenv('DB_HOST') !== false ? getenv('DB_HOST') : 'localhost'; - $user = getenv('DB_USER') !== false ? getenv('DB_USER') : 'apache'; - $pass = getenv('DB_PASS') !== false ? getenv('DB_PASS') : 'apache'; - $name = getenv('DB_NAME') !== false ? getenv('DB_NAME') : 'react_mysql_test'; - - return [ - $host, - $user, - $pass, - $name, - ]; - } - - protected function initDatabase() - { - if (!self::$initialized) - { - // While this package is focused on mysqli async, we can use - // PDO to initialize the database structure efficiently. - $this->getPdoConnection() - ->exec(file_get_contents(__DIR__ . '/sql.sql')); - - self::$initialized = true; - } - } - - protected function getPdoConnection() - { - if (is_null(self::$pdo)) - { - list($host, $user, $pass, $name) = $this->getCredentials(); - $dsn = 'mysql:host=' . $host . ';dbname=' . $name; - self::$pdo = new \PDO($dsn, $user, $pass); - } - - return self::$pdo; - } - - /** - * Note, do not close the connection. It is reused throughout the tests. - * - * @return \mysqli - */ - protected function getMysqliConnection() - { - if (is_null(self::$mysqli)) - { - list($host, $user, $pass, $name) = $this->getCredentials(); - self::$mysqli = new \mysqli($host, $user, $pass, $name); - } - - return self::$mysqli; - } -} diff --git a/tests/TestCaseTest.php b/tests/TestCaseTest.php deleted file mode 100644 index 17f6846..0000000 --- a/tests/TestCaseTest.php +++ /dev/null @@ -1,55 +0,0 @@ -assertTrue(true); - } - - public function testExtendedAssert() - { - foreach ([ - [ - 'a b', - 'a c', - ], - [ - 'alpha beta', - 'alpha delta', - ], - [ - 'ab', - 'a b', - ], - [ - ' a bc', - ' abc', - ], - ] as $test) - { - $this->assertStringNotEqualsIgnoreSpacing($test[0], $test[1]); - } - - foreach ([ - [ - // variable internal spacing - 'a b', - 'a b', - ], - [ - // variable spacing, longer text, more instances - 'alpha beta delta gamma', - 'alpha beta delta gamma', - ], - [ - // Trailing and Leading spaces. - ' a b c', - 'a b c ', - ], - ] as $test) - { - $this->assertStringEqualsIgnoreSpacing($test[0], $test[1]); - } - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d21c14d..ad5c2ee 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,3 +1,5 @@