diff --git a/README.md b/README.md index 5bf8e4d..811344a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,124 @@ -# react-mysql -Nuclear MySQL Reactor +# ReactMysql +Non-blocking MySQLi database access with PHP. +Designed to work with [reactphp/react](https://github.com/reactphp/react). -# Examples -Connection::init($loop); +## Working -Connection::query('SELECT * FROM `table` WHERE `column` = ? AND `column2` = ?;', ['red', 'white']) - ->then(function($result) { ... }); +This __is__ working. But it is nowhere near complete. -Connection::query returns a promise. This has all of the normal promise interface options. + $ ./run + Starting loop... + DB Created. + Run Query: 0 + Found rows: 0 + Run Query: 1 + Found rows: 1 + Current memory usage: 735.117K + Run Query: 2 + Found rows: 0 + Run Query: 3 + Found rows: 1 + Run Query: 4 + Found rows: 1 + Current memory usage: 735.117K + Run Query: 5 + Found rows: 0 + Current memory usage: 733.602K + Current memory usage: 733.602K + Current memory usage: 733.602K + Loop finished, all timers halted. -# Credits -Inspiration from: - - https://github.com/kaja47/async-mysql - - https://github.com/bixuehujin/reactphp-mysql +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! + +## TODO + +A lot. + +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 +injectable query builder that is not tied to a massive framework. + +## Plans (Future Examples) + +These are just plans for now. It may change wildly as we develop. + +### Current Development Example + +Here is an example of what is currently working for the most part. + + $loop = React\EventLoop\Factory::create(); + + ConnectionFactory::init($loop); + + $db = new \DustinGraham\ReactMysql\Database(); + + $db->createCommand("SELECT * FROM `table` WHERE id = :id;", [':id' => $id]) + ->execute()->then( + function($result) + { + $rows = $result->fetch_all(MYSQLI_ASSOC); + $result->close(); + + // Do something with $rows. + } + ); + + +### Original Big Picture Plans + +Here are some examples of how it may be, eventually. +It would be nice to hide away some of the current boilerplate. + + Connection::init($loop); + + Connection::query( + 'SELECT * FROM `table` WHERE `column` = ? AND `column2` = ?;', + ['red', 'white'] + )->then(function($result) { ... }); + + Connection::query(...) returns a promise. + + $db = new Database(); + $db->createCommand('SELECT * FROM table WHERE id = :id', [':id' => 1]) + ->execute() + ->then(function($results) { + echo $results[0]->name; + }); + + +And another idea... + + DB::loadModel('id', ' =', '3')->then(function($model) use ($socket) { + $socket->send('Your name is '.$model->name); + }); + +## Difficulties + +There were many difficulties. + +At this point, I can not find any libraries that handle parameterized queries +without using PDO or prepared statements. + +MYSQLI_ASYNC does not support prepared statements and parameter binding. So we had to write it ourselves. + +The mysqli::real_escape_string requires a link. But, the link is one of many. +Last minute escaping once the command and connection were married from the pool. +Could potentially have one dedicated link for escaping. + +## Credits + +Much appreciation to the hard work over at [reactphp/react](https://github.com/reactphp/react). + +Inspired by similar projects: + - [kaja47/async-mysql](https://github.com/kaja47/async-mysql) + - [bixuehujin/reactphp-mysql](https://github.com/bixuehujin/reactphp-mysql) + +## License + +DustinGraham/ReactMysql is released under the [MIT](https://github.com/dustingraham/react-mysql/blob/master/LICENSE) license. diff --git a/composer.json b/composer.json index 2c6f372..6ecd855 100644 --- a/composer.json +++ b/composer.json @@ -18,5 +18,10 @@ "psr-4": { "DustinGraham\\ReactMysql\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "DustinGraham\\ReactMysql\\Tests\\": "tests/" + } } } diff --git a/run b/run new file mode 100755 index 0000000..5f0fc4c --- /dev/null +++ b/run @@ -0,0 +1,56 @@ +#!/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 `react`.`react` 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 new file mode 100644 index 0000000..75ace91 --- /dev/null +++ b/src/Command.php @@ -0,0 +1,78 @@ +db = $database; + $this->sql = $sql; + } + + /** + * @param string|array $key + * @param string|null $value + * @return $this + */ + public function bind($key, $value = null) + { + if (is_array($key)) + { + // TODO: Is this cludgy? + $this->bindValues($key); + } + else + { + $this->params[$key] = $value; + } + + 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) + { + $this->params = $connection->escape($this->params); + + return strtr($this->sql, $this->params); + } + + /** + * @return \React\Promise\PromiseInterface + */ + public function execute() + { + return $this->db->executeCommand($this); + } +} diff --git a/src/Connection.php b/src/Connection.php new file mode 100644 index 0000000..8bf6666 --- /dev/null +++ b/src/Connection.php @@ -0,0 +1,101 @@ +mysqli = $mysqli; + $this->loop = $loop; + } + + public function escape($data) + { + if (is_array($data)) + { + $data = array_map([$this, 'escape'], $data); + } + else + { + $data = $this->mysqli->real_escape_string($data); + } + + return $data; + } + + public function execute(Command $command) + { + $query = $command->getPreparedQuery($this); + + $status = $this->mysqli->query($query, 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(new \Exception($this->mysqli->error)); + } + else + { + // Success!! + $deferred->resolve($result); + } + } + else if ($error) + { + $deferred->reject(new \Exception($this->mysqli->error)); + } + else if ($reject) + { + $deferred->reject(new \Exception($this->mysqli->error)); + } + + // If poll yielded something for this connection, we're done! + if ($read || $error || $reject) + { + $timer->cancel(); + } + } + ); + + return $deferred->promise(); + } +} diff --git a/src/ConnectionFactory.php b/src/ConnectionFactory.php new file mode 100644 index 0000000..4c681cd --- /dev/null +++ b/src/ConnectionFactory.php @@ -0,0 +1,36 @@ +pool = new \SplObjectStorage(); + $this->available = new \SplQueue(); + $this->waiting = new \SplQueue(); + } + + /** + * We use a promise in case all connections are busy. + * + * @return \React\Promise\PromiseInterface + */ + public function getConnection() + { + // First check idle connections. + if ($this->available->count() > 0) + { + $connection = $this->available->dequeue(); + + return \React\Promise\resolve($connection); + } + + // Check if we have max connections + if ($this->pool->count() >= $this->maxConnections) + { + $deferred = new Deferred(); + $this->waiting->enqueue($deferred); + + return $deferred->promise(); + } + + // Otherwise, create a new connection + $connection = ConnectionFactory::createConnection(); + + $this->pool->attach($connection); + + return \React\Promise\resolve($connection); + } + + /** + * Once a connection has finished being used... + * @param \mysqli $connection + */ + 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); + } + + // Otherwise, move it to the idle queue. + $this->available->enqueue($connection); + } +} diff --git a/src/Database.php b/src/Database.php new file mode 100644 index 0000000..a74a7b0 --- /dev/null +++ b/src/Database.php @@ -0,0 +1,56 @@ +pool = new ConnectionPool(); + } + + /** + * @param string|null $sql + * @param array $params + * @return Command + */ + public function createCommand($sql = null, $params = []) + { + $command = new Command($this, $sql); + + return $command->bindValues($params); + } + + /** + * @param Command $command + * @return \React\Promise\PromiseInterface + */ + public function executeCommand(Command $command) + { + $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(); + }) + ->always(function () use ($connection) + { + // Ensure we always return the connection to the pool. + $this->pool->releaseConnection($connection); + }); + }); + + return $deferred->promise(); + } +} diff --git a/tests/DatabaseTest.php b/tests/DatabaseTest.php new file mode 100644 index 0000000..59c24d4 --- /dev/null +++ b/tests/DatabaseTest.php @@ -0,0 +1,176 @@ +createCommand(); + + $this->assertInstanceOf(\DustinGraham\ReactMysql\Command::class, $command); + + $command->bindValues([]); + } + + public function testMysqliConnection() + { + $c = \mysqli_connect('localhost', 'react', 'react', 'react'); + + $this->assertInstanceOf(\mysqli::class, $c); + + $this->assertNull($c->connect_error); + + $this->assertEquals(0, $c->connect_errno); + + // Don't know if we care about these. + // This is what the development environment was. + // We can remove these as we understand them better. + $this->assertEquals(10, $c->protocol_version); + $this->assertEquals(50011, $c->client_version); + $this->assertEquals(50505, $c->server_version); + $this->assertEquals(0, $c->warning_count); + $this->assertEquals('00000', $c->sqlstate); + + $c->close(); + } + + public function testMysqliSynchronous() + { + $c = \mysqli_connect('localhost', 'react', 'react', 'react'); + + $result = $c->query('SELECT * FROM ' . $this->tableName); + $this->assertEquals(3, $result->num_rows); + + $tempTableName = 'temptable123'; + $c->query('CREATE TEMPORARY TABLE ' . $tempTableName . ' LIKE ' . $this->tableName); + $result = $c->query('SELECT * FROM ' . $tempTableName); + $this->assertEquals(0, $result->num_rows); + + $stmt = $c->prepare('INSERT INTO ' . $tempTableName . ' (`id`, `created_by`, `created_for`, `created_at`) VALUES (?, ?, ?, ?)'); + + $stmt->bind_param('isid', $id, $created_by, $created_for, $created_at); + + $id = null; + $created_by = 'john'; + $created_for = 3; + $created_at = 'NOW()'; + + $stmt->execute(); + $this->assertEquals(1, $stmt->affected_rows, 'Did not insert the row.'); + $stmt->close(); + + $c->close(); + } + + public function testMysqliAsynchronous() + { + $c = \mysqli_connect('localhost', 'react', 'react', 'react'); + + $c->query('SELECT * FROM ' . $this->tableName, MYSQLI_ASYNC); + + $result = $c->reap_async_query(); + $this->assertEquals(3, $result->num_rows); + + $c->close(); + } + + public function testCreateCommandGetPromise() + { + $db = new \DustinGraham\ReactMysql\Database(); + + $cmd = $db->createCommand(); + $cmd->sql = 'SELECT * FROM ' . $this->tableName . ' WHERE id = :id'; + $cmd->bind(':id', 1); + + $promise = $cmd->execute(); + $this->assertInstanceOf(\React\Promise\PromiseInterface::class, $promise); + + //// + + $promise = $db->createCommand( + 'SELECT * FROM ' . $this->tableName . ' WHERE id = :test', + [':test', 1] + )->execute(); + $this->assertInstanceOf(\React\Promise\PromiseInterface::class, $promise); + } + + public function testCommandResolvedResults() + { + $this->markTestSkipped('Still to do.'); + + $didItWork = false; + $db = new \DustinGraham\ReactMysql\Database(); + $db->createCommand( + 'SELECT * FROM ' . $this->tableName . ' WHERE id = :test', + [':test', 1] + )->execute()->then( + function ($results) use (&$didItWork) + { + // TODO + $didItWork = count($results) > 0; + } + ); + + // TODO: This probably won't work. + $this->assertTrue($didItWork, 'It did not work.'); + } + + public function testAssertStrings() + { + $this->assertStringEqualsIgnoreSpacing('yes no', 'yes no'); + } + + public function testSimpleCommandParameterBinding() + { + $db = new \DustinGraham\ReactMysql\Database(); + + $cmd = $db->createCommand(); + $cmd->sql = 'SELECT * FROM ' . $this->tableName . ' WHERE id = :id'; + $cmd->bind(':id', 1); + + $connection = \DustinGraham\ReactMysql\ConnectionFactory::createConnection(); + $query = $cmd->getPreparedQuery($connection); + + $this->assertEquals('SELECT * FROM ' . $this->tableName . ' WHERE id = 1', $query); + } + + public function testComplexCommandParameterBinding() + { + $this->markTestSkipped('TODO: Implement complex binding.'); + + $db = new \DustinGraham\ReactMysql\Database(); + + $cmd = $db->createCommand(); + $cmd->sql = " + INSERT INTO {$this->tableName} ( + `id`, + `created_by`, + `created_for`, + `created_at` + ) VALUES ( + :id, + :created_by, + :created_for, + :created_at + ); + "; + + $cmd->bind([ + ':id' => null, + ':created_by' => 'neth', + ':created_for' => 3, + ':created_at' => 'NOW()', + ]); + + $connection = \DustinGraham\ReactMysql\ConnectionFactory::createConnection(); + $query = $cmd->getPreparedQuery($connection); + + $this->assertStringEqualsIgnoreSpacing( + "INSERT INTO `react`.`react` ( `id`, `created_by`, `created_for`, `created_at` ) VALUES ( NULL, 'test', '3', NOW() );", + $query + ); + } +} diff --git a/tests/ReactTest.php b/tests/ReactTest.php new file mode 100644 index 0000000..fe64758 --- /dev/null +++ b/tests/ReactTest.php @@ -0,0 +1,28 @@ +markTestSkipped('Fix me'); + + $loop = \React\EventLoop\Factory::create(); + $driver = new \DustinGraham\ReactMysql\Connection($loop); + + $that = $this; + $driver->query('SELECT * FROM users;')->then( + function (\mysqli_result $result) use ($that) + { + $rows = $result->fetch_all(MYSQLI_ASSOC); + $result->close(); + + $that->assertCount(2, count($rows)); + }, + function () use ($that) + { + $that->fail('Query failed.'); + }); + + //$loop->tick(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..8a2632f --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,31 @@ +assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase); + } + + public function assertStringNotEqualsIgnoreSpacing($expected, $actual, $message = '', $delta = 0.0, $maxDepth = 10, $canonicalize = false, $ignoreCase = false) + { + $expected = preg_replace('/\s+/', ' ', trim($expected)); + $actual = preg_replace('/\s+/', ' ', trim($actual)); + + $this->assertNotEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase); + } +} diff --git a/tests/TestCaseTest.php b/tests/TestCaseTest.php new file mode 100644 index 0000000..17f6846 --- /dev/null +++ b/tests/TestCaseTest.php @@ -0,0 +1,55 @@ +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 991ea43..d21c14d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,3 +1,3 @@