From 52273c7044720b076f1886abc5df67fc4d125dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 28 Apr 2019 11:58:11 +0200 Subject: [PATCH 1/2] Refactor to use dedicated Factory to open Database instance This is done to achieve better separation of concerns in preparation for creating multiple database launchers for Windows support in the future. --- README.md | 29 ++++++-- examples/insert.php | 7 +- examples/search.php | 7 +- src/Database.php | 108 ++------------------------- src/Factory.php | 123 +++++++++++++++++++++++++++++++ tests/FunctionalDatabaseTest.php | 94 +++++++++++++---------- 6 files changed, 216 insertions(+), 152 deletions(-) create mode 100644 src/Factory.php diff --git a/README.md b/README.md index 3b1ed36..2f69124 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ built on top of [ReactPHP](https://reactphp.org/). * [Quickstart example](#quickstart-example) * [Usage](#usage) + * [Factory](#factory) + * [open()](#open) * [Database](#database) * [exec()](#exec) * [query()](#query) @@ -27,9 +29,10 @@ existing SQLite database file (or automatically create it on first run) and then ```php $loop = React\EventLoop\Factory::create(); +$factory = new Clue\React\SQLite\Factory($loop); $name = 'Alice'; -Clue\React\SQLite\Database::open($loop, 'users.db')->then( +$factory->open('users.db')->then( function (Clue\React\SQLite\Database $db) use ($name) { $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); @@ -53,15 +56,19 @@ See also the [examples](examples). ## Usage -### Database +### Factory -The `Database` class represents a connection that is responsible for -comunicating with your SQLite database wrapper, managing the connection state -and sending your database queries. +The `Factory` is responsible for opening your [`Database`](#database) instance. +It also registers everything with the main [`EventLoop`](https://github.com/reactphp/event-loop#usage). + +```php +$loop = React\EventLoop\Factory::create(); +$factory = new Clue\React\SQLite\Factory($loop); +``` #### open() -The static `open(LoopInterface $loop, string $filename, int $flags = null): PromiseInterface` method can be used to +The `open(string $filename, int $flags = null): PromiseInterface` method can be used to open a new database connection for the given SQLite database file. This method returns a promise that will resolve with a `Database` on @@ -71,7 +78,7 @@ to run all SQLite commands and queries in a separate process without blocking the main process. ```php -Database::open($loop, 'users.db')->then(function (Database $db) { +$factory->open('users.db')->then(function (Database $db) { // database ready // $db->query('INSERT INTO users (name) VALUES ("test")'); // $db->quit(); @@ -84,7 +91,7 @@ The optional `$flags` parameter is used to determine how to open the SQLite database. By default, open uses `SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE`. ```php -Database::open($loop, 'users.db', SQLITE3_OPEN_READONLY)->then(function (Database $db) { +$factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (Database $db) { // database ready (read-only) // $db->quit(); }, function (Exception $e) { @@ -92,6 +99,12 @@ Database::open($loop, 'users.db', SQLITE3_OPEN_READONLY)->then(function (Databas }); ``` +### Database + +The `Database` class represents a connection that is responsible for +comunicating with your SQLite database wrapper, managing the connection state +and sending your database queries. + #### exec() The `exec(string $query): PromiseInterface` method can be used to diff --git a/examples/insert.php b/examples/insert.php index ad6f8ab..bdc0db8 100644 --- a/examples/insert.php +++ b/examples/insert.php @@ -1,15 +1,16 @@ then(function (Database $db) use ($n) { +$factory->open('test.db')->then(function (Database $db) use ($n) { $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); for ($i = 0; $i < $n; ++$i) { diff --git a/examples/search.php b/examples/search.php index fa13fb6..a021813 100644 --- a/examples/search.php +++ b/examples/search.php @@ -1,15 +1,16 @@ then(function (Database $db) use ($search){ +$factory->open('test.db')->then(function (Database $db) use ($search){ $db->query('SELECT * FROM foo WHERE bar LIKE ?', ['%' . $search . '%'])->then(function (Result $result) { echo 'Found ' . count($result->rows) . ' rows: ' . PHP_EOL; echo implode("\t", $result->columns) . PHP_EOL; diff --git a/src/Database.php b/src/Database.php index f6ebf76..1e74382 100644 --- a/src/Database.php +++ b/src/Database.php @@ -5,7 +5,6 @@ use Clue\React\NDJson\Decoder; use Evenement\EventEmitter; use React\ChildProcess\Process; -use React\EventLoop\LoopInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; @@ -45,109 +44,17 @@ */ class Database extends EventEmitter { - /** - * Opens a new database connection for the given SQLite database file. - * - * This method returns a promise that will resolve with a `Database` on - * success or will reject with an `Exception` on error. The SQLite extension - * is inherently blocking, so this method will spawn an SQLite worker process - * to run all SQLite commands and queries in a separate process without - * blocking the main process. - * - * ```php - * Database::open($loop, 'users.db')->then(function (Database $db) { - * // database ready - * // $db->query('INSERT INTO users (name) VALUES ("test")'); - * // $db->quit(); - * }, function (Exception $e) { - * echo 'Error: ' . $e->getMessage() . PHP_EOL; - * }); - * ``` - * - * The optional `$flags` parameter is used to determine how to open the - * SQLite database. By default, open uses `SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE`. - * - * ```php - * Database::open($loop, 'users.db', SQLITE3_OPEN_READONLY)->then(function (Database $db) { - * // database ready (read-only) - * // $db->quit(); - * }, function (Exception $e) { - * echo 'Error: ' . $e->getMessage() . PHP_EOL; - * }); - * ``` - * - * @param LoopInterface $loop - * @param string $filename - * @param ?int $flags - * @return PromiseInterface Resolves with Database instance or rejects with Exception - */ - public static function open(LoopInterface $loop, $filename, $flags = null) - { - $command = 'exec ' . \escapeshellarg(\PHP_BINARY) . ' ' . \escapeshellarg(__DIR__ . '/../res/sqlite-worker.php'); - - // Try to get list of all open FDs (Linux/Mac and others) - $fds = @\scandir('/dev/fd'); - - // Otherwise try temporarily duplicating file descriptors in the range 0-1024 (FD_SETSIZE). - // This is known to work on more exotic platforms and also inside chroot - // environments without /dev/fd. Causes many syscalls, but still rather fast. - // @codeCoverageIgnoreStart - if ($fds === false) { - $fds = array(); - for ($i = 0; $i <= 1024; ++$i) { - $copy = @\fopen('php://fd/' . $i, 'r'); - if ($copy !== false) { - $fds[] = $i; - \fclose($copy); - } - } - } - // @codeCoverageIgnoreEnd - - // launch process with default STDIO pipes - $pipes = array( - array('pipe', 'r'), - array('pipe', 'w'), - array('pipe', 'w') - ); - - // do not inherit open FDs by explicitly overwriting existing FDs with dummy files - // additionally, close all dummy files in the child process again - foreach ($fds as $fd) { - if ($fd > 2) { - $pipes[$fd] = array('file', '/dev/null', 'r'); - $command .= ' ' . $fd . '>&-'; - } - } - - // default `sh` only accepts single-digit FDs, so run in bash if needed - if ($fds && \max($fds) > 9) { - $command = 'exec bash -c ' . \escapeshellarg($command); - } - - $process = new Process($command, null, null, $pipes); - $process->start($loop); - - $db = new Database($process); - $args = array($filename); - if ($flags !== null) { - $args[] = $flags; - } - - return $db->send('open', $args)->then(function () use ($db) { - return $db; - }, function ($e) use ($db) { - $db->close(); - throw $e; - }); - } - private $process; private $pending = array(); private $id = 0; private $closed = false; - private function __construct(Process $process) + /** + * @internal see Factory instead + * @see Factory + * @param Process $process + */ + public function __construct(Process $process) { $this->process = $process; @@ -363,7 +270,8 @@ public function close() $this->removeAllListeners(); } - private function send($method, array $params) + /** @internal */ + public function send($method, array $params) { if (!$this->process->stdin->isWritable()) { return \React\Promise\reject(new \RuntimeException('Database closed')); diff --git a/src/Factory.php b/src/Factory.php new file mode 100644 index 0000000..9524990 --- /dev/null +++ b/src/Factory.php @@ -0,0 +1,123 @@ +loop = $loop; + } + + /** + * Opens a new database connection for the given SQLite database file. + * + * This method returns a promise that will resolve with a `Database` on + * success or will reject with an `Exception` on error. The SQLite extension + * is inherently blocking, so this method will spawn an SQLite worker process + * to run all SQLite commands and queries in a separate process without + * blocking the main process. + * + * ```php + * $factory->open('users.db')->then(function (Database $db) { + * // database ready + * // $db->query('INSERT INTO users (name) VALUES ("test")'); + * // $db->quit(); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * The optional `$flags` parameter is used to determine how to open the + * SQLite database. By default, open uses `SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE`. + * + * ```php + * $factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (Database $db) { + * // database ready (read-only) + * // $db->quit(); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * @param string $filename + * @param ?int $flags + * @return PromiseInterface Resolves with Database instance or rejects with Exception + */ + public function open($filename, $flags = null) + { + $command = 'exec ' . \escapeshellarg(\PHP_BINARY) . ' ' . \escapeshellarg(__DIR__ . '/../res/sqlite-worker.php'); + + // Try to get list of all open FDs (Linux/Mac and others) + $fds = @\scandir('/dev/fd'); + + // Otherwise try temporarily duplicating file descriptors in the range 0-1024 (FD_SETSIZE). + // This is known to work on more exotic platforms and also inside chroot + // environments without /dev/fd. Causes many syscalls, but still rather fast. + // @codeCoverageIgnoreStart + if ($fds === false) { + $fds = array(); + for ($i = 0; $i <= 1024; ++$i) { + $copy = @\fopen('php://fd/' . $i, 'r'); + if ($copy !== false) { + $fds[] = $i; + \fclose($copy); + } + } + } + // @codeCoverageIgnoreEnd + + // launch process with default STDIO pipes + $pipes = array( + array('pipe', 'r'), + array('pipe', 'w'), + array('pipe', 'w') + ); + + // do not inherit open FDs by explicitly overwriting existing FDs with dummy files + // additionally, close all dummy files in the child process again + foreach ($fds as $fd) { + if ($fd > 2) { + $pipes[$fd] = array('file', '/dev/null', 'r'); + $command .= ' ' . $fd . '>&-'; + } + } + + // default `sh` only accepts single-digit FDs, so run in bash if needed + if ($fds && \max($fds) > 9) { + $command = 'exec bash -c ' . \escapeshellarg($command); + } + + $process = new Process($command, null, null, $pipes); + $process->start($this->loop); + + $db = new Database($process); + $args = array($filename); + if ($flags !== null) { + $args[] = $flags; + } + + return $db->send('open', $args)->then(function () use ($db) { + return $db; + }, function ($e) use ($db) { + $db->close(); + throw $e; + }); + } +} diff --git a/tests/FunctionalDatabaseTest.php b/tests/FunctionalDatabaseTest.php index 448116e..1a28a43 100644 --- a/tests/FunctionalDatabaseTest.php +++ b/tests/FunctionalDatabaseTest.php @@ -1,17 +1,18 @@ open(':memory:'); $promise->then( $this->expectCallableOnceWith($this->isInstanceOf('Clue\React\SQLite\Database')) @@ -26,9 +27,10 @@ public function testOpenMemoryDatabaseResolvesWithDatabaseAndRunsUntilClose() public function testOpenMemoryDatabaseResolvesWithDatabaseAndRunsUntilQuit() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $promise->then( $this->expectCallableOnceWith($this->isInstanceOf('Clue\React\SQLite\Database')) @@ -50,9 +52,10 @@ public function testOpenMemoryDatabaseShouldNotInheritActiveFileDescriptors() $this->markTestSkipped('Platform does not prevent binding to same address (Windows?)'); } - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); // close server and ensure we can start a new server on the previous address // the pending SQLite process should not inherit the existing server socket @@ -70,9 +73,10 @@ public function testOpenMemoryDatabaseShouldNotInheritActiveFileDescriptors() public function testOpenInvalidPathRejects() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, '/dev/foo/bar'); + $promise = $factory->open('/dev/foo/bar'); $promise->then( null, @@ -84,9 +88,10 @@ public function testOpenInvalidPathRejects() public function testOpenInvalidFlagsRejects() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, '::memory::', SQLITE3_OPEN_READONLY); + $promise = $factory->open('::memory::', SQLITE3_OPEN_READONLY); $promise->then( null, @@ -98,9 +103,10 @@ public function testOpenInvalidFlagsRejects() public function testQuitResolvesAndRunsUntilQuit() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $once = $this->expectCallableOnce(); $promise->then(function (Database $db) use ($once){ @@ -118,9 +124,10 @@ public function testQuitResolvesAndRunsUntilQuitWhenParentHasManyFileDescriptors $servers[] = stream_socket_server('tcp://127.0.0.1:0'); } - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $once = $this->expectCallableOnce(); $promise->then(function (Database $db) use ($once){ @@ -136,9 +143,10 @@ public function testQuitResolvesAndRunsUntilQuitWhenParentHasManyFileDescriptors public function testQuitTwiceWillRejectSecondCall() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $once = $this->expectCallableOnce(); $promise->then(function (Database $db) use ($once){ @@ -151,9 +159,10 @@ public function testQuitTwiceWillRejectSecondCall() public function testQueryIntegerResolvesWithResultWithTypeIntegerAndRunsUntilQuit() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $data = null; $promise->then(function (Database $db) use (&$data){ @@ -171,9 +180,10 @@ public function testQueryIntegerResolvesWithResultWithTypeIntegerAndRunsUntilQui public function testQueryStringResolvesWithResultWithTypeStringAndRunsUntilQuit() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $data = null; $promise->then(function (Database $db) use (&$data){ @@ -191,9 +201,10 @@ public function testQueryStringResolvesWithResultWithTypeStringAndRunsUntilQuit( public function testQueryIntegerPlaceholderPositionalResolvesWithResultWithTypeIntegerAndRunsUntilQuit() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $data = null; $promise->then(function (Database $db) use (&$data){ @@ -211,9 +222,10 @@ public function testQueryIntegerPlaceholderPositionalResolvesWithResultWithTypeI public function testQueryIntegerPlaceholderNamedResolvesWithResultWithTypeIntegerAndRunsUntilQuit() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $data = null; $promise->then(function (Database $db) use (&$data){ @@ -231,9 +243,10 @@ public function testQueryIntegerPlaceholderNamedResolvesWithResultWithTypeIntege public function testQueryNullPlaceholderPositionalResolvesWithResultWithTypeNullAndRunsUntilQuit() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $data = null; $promise->then(function (Database $db) use (&$data){ @@ -251,9 +264,10 @@ public function testQueryNullPlaceholderPositionalResolvesWithResultWithTypeNull public function testQueryRejectsWhenQueryIsInvalid() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $once = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); $promise->then(function (Database $db) use ($once){ @@ -267,9 +281,10 @@ public function testQueryRejectsWhenQueryIsInvalid() public function testQueryRejectsWhenClosedImmediately() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $once = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); $promise->then(function (Database $db) use ($once){ @@ -283,9 +298,10 @@ public function testQueryRejectsWhenClosedImmediately() public function testExecCreateTableResolvesWithResultWithoutRows() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $data = 'n/a'; $promise->then(function (Database $db) use (&$data){ @@ -303,9 +319,10 @@ public function testExecCreateTableResolvesWithResultWithoutRows() public function testExecRejectsWhenClosedImmediately() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $once = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); $promise->then(function (Database $db) use ($once){ @@ -319,9 +336,10 @@ public function testExecRejectsWhenClosedImmediately() public function testExecRejectsWhenAlreadyClosed() { - $loop = Factory::create(); + $loop = React\EventLoop\Factory::create(); + $factory = new Factory($loop); - $promise = Database::open($loop, ':memory:'); + $promise = $factory->open(':memory:'); $once = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); $promise->then(function (Database $db) use ($once){ From c957d834905ddd55dfe08b85edfb1a1ddab1cdce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 28 Apr 2019 16:43:16 +0200 Subject: [PATCH 2/2] Split DatabaseInterface and internal ProcessIoDatabase implementation This is done to achieve better separation of concerns in preparation for creating different database communication implementations for Windows support in the future. --- README.md | 18 +-- examples/insert.php | 4 +- examples/search.php | 4 +- src/{Database.php => DatabaseInterface.php} | 127 +----------------- src/Factory.php | 13 +- src/Io/ProcessIoDatabase.php | 139 ++++++++++++++++++++ tests/FunctionalDatabaseTest.php | 38 +++--- tests/{ => Io}/DatabaseTest.php | 34 ++--- 8 files changed, 202 insertions(+), 175 deletions(-) rename src/{Database.php => DatabaseInterface.php} (63%) create mode 100644 src/Io/ProcessIoDatabase.php rename tests/{ => Io}/DatabaseTest.php (88%) diff --git a/README.md b/README.md index 2f69124..518865e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ built on top of [ReactPHP](https://reactphp.org/). * [Usage](#usage) * [Factory](#factory) * [open()](#open) - * [Database](#database) + * [DatabaseInterface](#databaseinterface) * [exec()](#exec) * [query()](#query) * [quit()](#quit) @@ -33,7 +33,7 @@ $factory = new Clue\React\SQLite\Factory($loop); $name = 'Alice'; $factory->open('users.db')->then( - function (Clue\React\SQLite\Database $db) use ($name) { + function (Clue\React\SQLite\DatabaseInterface $db) use ($name) { $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); $db->query('INSERT INTO foo (bar) VALUES (?)', array($name))->then( @@ -58,7 +58,7 @@ See also the [examples](examples). ### Factory -The `Factory` is responsible for opening your [`Database`](#database) instance. +The `Factory` is responsible for opening your [`DatabaseInterface`](#databaseinterface) instance. It also registers everything with the main [`EventLoop`](https://github.com/reactphp/event-loop#usage). ```php @@ -68,17 +68,17 @@ $factory = new Clue\React\SQLite\Factory($loop); #### open() -The `open(string $filename, int $flags = null): PromiseInterface` method can be used to +The `open(string $filename, int $flags = null): PromiseInterface` method can be used to open a new database connection for the given SQLite database file. -This method returns a promise that will resolve with a `Database` on +This method returns a promise that will resolve with a `DatabaseInterface` on success or will reject with an `Exception` on error. The SQLite extension is inherently blocking, so this method will spawn an SQLite worker process to run all SQLite commands and queries in a separate process without blocking the main process. ```php -$factory->open('users.db')->then(function (Database $db) { +$factory->open('users.db')->then(function (DatabaseInterface $db) { // database ready // $db->query('INSERT INTO users (name) VALUES ("test")'); // $db->quit(); @@ -91,7 +91,7 @@ The optional `$flags` parameter is used to determine how to open the SQLite database. By default, open uses `SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE`. ```php -$factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (Database $db) { +$factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (DatabaseInterface $db) { // database ready (read-only) // $db->quit(); }, function (Exception $e) { @@ -99,9 +99,9 @@ $factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (Database $db) }); ``` -### Database +### DatabaseInterface -The `Database` class represents a connection that is responsible for +The `DatabaseInterface` represents a connection that is responsible for comunicating with your SQLite database wrapper, managing the connection state and sending your database queries. diff --git a/examples/insert.php b/examples/insert.php index bdc0db8..706a173 100644 --- a/examples/insert.php +++ b/examples/insert.php @@ -1,6 +1,6 @@ open('test.db')->then(function (Database $db) use ($n) { +$factory->open('test.db')->then(function (DatabaseInterface $db) use ($n) { $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); for ($i = 0; $i < $n; ++$i) { diff --git a/examples/search.php b/examples/search.php index a021813..e8a7a97 100644 --- a/examples/search.php +++ b/examples/search.php @@ -1,6 +1,6 @@ open('test.db')->then(function (Database $db) use ($search){ +$factory->open('test.db')->then(function (DatabaseInterface $db) use ($search){ $db->query('SELECT * FROM foo WHERE bar LIKE ?', ['%' . $search . '%'])->then(function (Result $result) { echo 'Found ' . count($result->rows) . ' rows: ' . PHP_EOL; echo implode("\t", $result->columns) . PHP_EOL; diff --git a/src/Database.php b/src/DatabaseInterface.php similarity index 63% rename from src/Database.php rename to src/DatabaseInterface.php index 1e74382..0325bdd 100644 --- a/src/Database.php +++ b/src/DatabaseInterface.php @@ -2,14 +2,11 @@ namespace Clue\React\SQLite; -use Clue\React\NDJson\Decoder; -use Evenement\EventEmitter; -use React\ChildProcess\Process; -use React\Promise\Deferred; +use Evenement\EventEmitterInterface; use React\Promise\PromiseInterface; /** - * The `Database` class represents a connection that is responsible for + * The `DatabaseInterface` represents a connection that is responsible for * communicating with your SQLite database wrapper, managing the connection state * and sending your database queries. * @@ -42,52 +39,8 @@ * * See also the [`close()`](#close) method. */ -class Database extends EventEmitter +interface DatabaseInterface extends EventEmitterInterface { - private $process; - private $pending = array(); - private $id = 0; - private $closed = false; - - /** - * @internal see Factory instead - * @see Factory - * @param Process $process - */ - public function __construct(Process $process) - { - $this->process = $process; - - $in = new Decoder($process->stdout, true, 512, 0, 16 * 1024 * 1024); - $in->on('data', function ($data) use ($in) { - if (!isset($data['id']) || !isset($this->pending[$data['id']])) { - $this->emit('error', array(new \RuntimeException('Invalid message received'))); - $in->close(); - return; - } - - /* @var Deferred $deferred */ - $deferred = $this->pending[$data['id']]; - unset($this->pending[$data['id']]); - - if (isset($data['error'])) { - $deferred->reject(new \RuntimeException( - isset($data['error']['message']) ? $data['error']['message'] : 'Unknown error', - isset($data['error']['code']) ? $data['error']['code'] : 0 - )); - } else { - $deferred->resolve($data['result']); - } - }); - $in->on('error', function (\Exception $e) { - $this->emit('error', array($e)); - $this->close(); - }); - $in->on('close', function () { - $this->close(); - }); - } - /** * Executes an async query. * @@ -129,16 +82,7 @@ public function __construct(Process $process) * @param string $sql SQL statement * @return PromiseInterface Resolves with Result instance or rejects with Exception */ - public function exec($sql) - { - return $this->send('exec', array($sql))->then(function ($data) { - $result = new Result(); - $result->changed = $data['changed']; - $result->insertId = $data['insertId']; - - return $result; - }); - } + public function exec($sql); /** * Performs an async query. @@ -196,18 +140,7 @@ public function exec($sql) * @param array $params Parameters which should be bound to query * @return PromiseInterface Resolves with Result instance or rejects with Exception */ - public function query($sql, array $params = array()) - { - return $this->send('query', array($sql, $params))->then(function ($data) { - $result = new Result(); - $result->changed = $data['changed']; - $result->insertId = $data['insertId']; - $result->columns = $data['columns']; - $result->rows = $data['rows']; - - return $result; - }); - } + public function query($sql, array $params = array()); /** * Quits (soft-close) the connection. @@ -225,14 +158,7 @@ public function query($sql, array $params = array()) * * @return PromiseInterface Resolves (with void) or rejects with Exception */ - public function quit() - { - $promise = $this->send('close', array()); - - $this->process->stdin->end(); - - return $promise; - } + public function quit(); /** * Force-close the connection. @@ -249,44 +175,5 @@ public function quit() * * @return void */ - public function close() - { - if ($this->closed) { - return; - } - - $this->closed = true; - foreach ($this->process->pipes as $pipe) { - $pipe->close(); - } - $this->process->terminate(); - - foreach ($this->pending as $one) { - $one->reject(new \RuntimeException('Database closed')); - } - $this->pending = array(); - - $this->emit('close'); - $this->removeAllListeners(); - } - - /** @internal */ - public function send($method, array $params) - { - if (!$this->process->stdin->isWritable()) { - return \React\Promise\reject(new \RuntimeException('Database closed')); - } - - $id = ++$this->id; - $this->process->stdin->write(\json_encode(array( - 'id' => $id, - 'method' => $method, - 'params' => $params - ), \JSON_UNESCAPED_SLASHES) . "\n"); - - $deferred = new Deferred(); - $this->pending[$id] = $deferred; - - return $deferred->promise(); - } + public function close(); } diff --git a/src/Factory.php b/src/Factory.php index 9524990..69aceef 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -4,13 +4,14 @@ use React\ChildProcess\Process; use React\EventLoop\LoopInterface; +use Clue\React\SQLite\Io\ProcessIoDatabase; class Factory { private $loop; /** - * The `Factory` is responsible for opening your [`Database`](#database) instance. + * The `Factory` is responsible for opening your [`DatabaseInterface`](#databaseinterface) instance. * It also registers everything with the main [`EventLoop`](https://github.com/reactphp/event-loop#usage). * * ```php @@ -28,14 +29,14 @@ public function __construct(LoopInterface $loop) /** * Opens a new database connection for the given SQLite database file. * - * This method returns a promise that will resolve with a `Database` on + * This method returns a promise that will resolve with a `DatabaseInterface` on * success or will reject with an `Exception` on error. The SQLite extension * is inherently blocking, so this method will spawn an SQLite worker process * to run all SQLite commands and queries in a separate process without * blocking the main process. * * ```php - * $factory->open('users.db')->then(function (Database $db) { + * $factory->open('users.db')->then(function (DatabaseInterface $db) { * // database ready * // $db->query('INSERT INTO users (name) VALUES ("test")'); * // $db->quit(); @@ -48,7 +49,7 @@ public function __construct(LoopInterface $loop) * SQLite database. By default, open uses `SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE`. * * ```php - * $factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (Database $db) { + * $factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (DatabaseInterface $db) { * // database ready (read-only) * // $db->quit(); * }, function (Exception $e) { @@ -58,7 +59,7 @@ public function __construct(LoopInterface $loop) * * @param string $filename * @param ?int $flags - * @return PromiseInterface Resolves with Database instance or rejects with Exception + * @return PromiseInterface Resolves with DatabaseInterface instance or rejects with Exception */ public function open($filename, $flags = null) { @@ -107,7 +108,7 @@ public function open($filename, $flags = null) $process = new Process($command, null, null, $pipes); $process->start($this->loop); - $db = new Database($process); + $db = new ProcessIoDatabase($process); $args = array($filename); if ($flags !== null) { $args[] = $flags; diff --git a/src/Io/ProcessIoDatabase.php b/src/Io/ProcessIoDatabase.php new file mode 100644 index 0000000..f3a4b27 --- /dev/null +++ b/src/Io/ProcessIoDatabase.php @@ -0,0 +1,139 @@ +process = $process; + + $in = new Decoder($process->stdout, true, 512, 0, 16 * 1024 * 1024); + $in->on('data', function ($data) use ($in) { + if (!isset($data['id']) || !isset($this->pending[$data['id']])) { + $this->emit('error', array(new \RuntimeException('Invalid message received'))); + $in->close(); + return; + } + + /* @var Deferred $deferred */ + $deferred = $this->pending[$data['id']]; + unset($this->pending[$data['id']]); + + if (isset($data['error'])) { + $deferred->reject(new \RuntimeException( + isset($data['error']['message']) ? $data['error']['message'] : 'Unknown error', + isset($data['error']['code']) ? $data['error']['code'] : 0 + )); + } else { + $deferred->resolve($data['result']); + } + }); + $in->on('error', function (\Exception $e) { + $this->emit('error', array($e)); + $this->close(); + }); + $in->on('close', function () { + $this->close(); + }); + } + + public function exec($sql) + { + return $this->send('exec', array($sql))->then(function ($data) { + $result = new Result(); + $result->changed = $data['changed']; + $result->insertId = $data['insertId']; + + return $result; + }); + } + + public function query($sql, array $params = array()) + { + return $this->send('query', array($sql, $params))->then(function ($data) { + $result = new Result(); + $result->changed = $data['changed']; + $result->insertId = $data['insertId']; + $result->columns = $data['columns']; + $result->rows = $data['rows']; + + return $result; + }); + } + + public function quit() + { + $promise = $this->send('close', array()); + + $this->process->stdin->end(); + + return $promise; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + foreach ($this->process->pipes as $pipe) { + $pipe->close(); + } + $this->process->terminate(); + + foreach ($this->pending as $one) { + $one->reject(new \RuntimeException('Database closed')); + } + $this->pending = array(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + /** @internal */ + public function send($method, array $params) + { + if (!$this->process->stdin->isWritable()) { + return \React\Promise\reject(new \RuntimeException('Database closed')); + } + + $id = ++$this->id; + $this->process->stdin->write(\json_encode(array( + 'id' => $id, + 'method' => $method, + 'params' => $params + ), \JSON_UNESCAPED_SLASHES) . "\n"); + + $deferred = new Deferred(); + $this->pending[$id] = $deferred; + + return $deferred->promise(); + } +} diff --git a/tests/FunctionalDatabaseTest.php b/tests/FunctionalDatabaseTest.php index 1a28a43..dcc7f28 100644 --- a/tests/FunctionalDatabaseTest.php +++ b/tests/FunctionalDatabaseTest.php @@ -1,6 +1,6 @@ open(':memory:'); $promise->then( - $this->expectCallableOnceWith($this->isInstanceOf('Clue\React\SQLite\Database')) + $this->expectCallableOnceWith($this->isInstanceOf('Clue\React\SQLite\DatabaseInterface')) ); - $promise->then(function (Database $db) { + $promise->then(function (DatabaseInterface $db) { $db->close(); }); @@ -33,10 +33,10 @@ public function testOpenMemoryDatabaseResolvesWithDatabaseAndRunsUntilQuit() $promise = $factory->open(':memory:'); $promise->then( - $this->expectCallableOnceWith($this->isInstanceOf('Clue\React\SQLite\Database')) + $this->expectCallableOnceWith($this->isInstanceOf('Clue\React\SQLite\DatabaseInterface')) ); - $promise->then(function (Database $db) { + $promise->then(function (DatabaseInterface $db) { $db->quit(); }); @@ -64,7 +64,7 @@ public function testOpenMemoryDatabaseShouldNotInheritActiveFileDescriptors() $this->assertTrue(is_resource($server)); fclose($server); - $promise->then(function (Database $db) { + $promise->then(function (DatabaseInterface $db) { $db->close(); }); @@ -109,7 +109,7 @@ public function testQuitResolvesAndRunsUntilQuit() $promise = $factory->open(':memory:'); $once = $this->expectCallableOnce(); - $promise->then(function (Database $db) use ($once){ + $promise->then(function (DatabaseInterface $db) use ($once){ $db->quit()->then($once); }); @@ -130,7 +130,7 @@ public function testQuitResolvesAndRunsUntilQuitWhenParentHasManyFileDescriptors $promise = $factory->open(':memory:'); $once = $this->expectCallableOnce(); - $promise->then(function (Database $db) use ($once){ + $promise->then(function (DatabaseInterface $db) use ($once){ $db->quit()->then($once); }); @@ -149,7 +149,7 @@ public function testQuitTwiceWillRejectSecondCall() $promise = $factory->open(':memory:'); $once = $this->expectCallableOnce(); - $promise->then(function (Database $db) use ($once){ + $promise->then(function (DatabaseInterface $db) use ($once){ $db->quit(); $db->quit()->then(null, $once); }); @@ -165,7 +165,7 @@ public function testQueryIntegerResolvesWithResultWithTypeIntegerAndRunsUntilQui $promise = $factory->open(':memory:'); $data = null; - $promise->then(function (Database $db) use (&$data){ + $promise->then(function (DatabaseInterface $db) use (&$data){ $db->query('SELECT 1 AS value')->then(function (Result $result) use (&$data) { $data = $result->rows; }); @@ -186,7 +186,7 @@ public function testQueryStringResolvesWithResultWithTypeStringAndRunsUntilQuit( $promise = $factory->open(':memory:'); $data = null; - $promise->then(function (Database $db) use (&$data){ + $promise->then(function (DatabaseInterface $db) use (&$data){ $db->query('SELECT "hellö" AS value')->then(function (Result $result) use (&$data) { $data = $result->rows; }); @@ -207,7 +207,7 @@ public function testQueryIntegerPlaceholderPositionalResolvesWithResultWithTypeI $promise = $factory->open(':memory:'); $data = null; - $promise->then(function (Database $db) use (&$data){ + $promise->then(function (DatabaseInterface $db) use (&$data){ $db->query('SELECT ? AS value', array(1))->then(function (Result $result) use (&$data) { $data = $result->rows; }); @@ -228,7 +228,7 @@ public function testQueryIntegerPlaceholderNamedResolvesWithResultWithTypeIntege $promise = $factory->open(':memory:'); $data = null; - $promise->then(function (Database $db) use (&$data){ + $promise->then(function (DatabaseInterface $db) use (&$data){ $db->query('SELECT :value AS value', array('value' => 1))->then(function (Result $result) use (&$data) { $data = $result->rows; }); @@ -249,7 +249,7 @@ public function testQueryNullPlaceholderPositionalResolvesWithResultWithTypeNull $promise = $factory->open(':memory:'); $data = null; - $promise->then(function (Database $db) use (&$data){ + $promise->then(function (DatabaseInterface $db) use (&$data){ $db->query('SELECT ? AS value', array(null))->then(function (Result $result) use (&$data) { $data = $result->rows; }); @@ -270,7 +270,7 @@ public function testQueryRejectsWhenQueryIsInvalid() $promise = $factory->open(':memory:'); $once = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); - $promise->then(function (Database $db) use ($once){ + $promise->then(function (DatabaseInterface $db) use ($once){ $db->query('nope')->then(null, $once); $db->quit(); @@ -287,7 +287,7 @@ public function testQueryRejectsWhenClosedImmediately() $promise = $factory->open(':memory:'); $once = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); - $promise->then(function (Database $db) use ($once){ + $promise->then(function (DatabaseInterface $db) use ($once){ $db->query('SELECT 1')->then(null, $once); $db->close(); @@ -304,7 +304,7 @@ public function testExecCreateTableResolvesWithResultWithoutRows() $promise = $factory->open(':memory:'); $data = 'n/a'; - $promise->then(function (Database $db) use (&$data){ + $promise->then(function (DatabaseInterface $db) use (&$data){ $db->exec('CREATE TABLE foo (bar STRING)')->then(function (Result $result) use (&$data) { $data = $result->rows; }); @@ -325,7 +325,7 @@ public function testExecRejectsWhenClosedImmediately() $promise = $factory->open(':memory:'); $once = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); - $promise->then(function (Database $db) use ($once){ + $promise->then(function (DatabaseInterface $db) use ($once){ $db->exec('USE a')->then(null, $once); $db->close(); @@ -342,7 +342,7 @@ public function testExecRejectsWhenAlreadyClosed() $promise = $factory->open(':memory:'); $once = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); - $promise->then(function (Database $db) use ($once){ + $promise->then(function (DatabaseInterface $db) use ($once){ $db->close(); $db->exec('USE a')->then(null, $once); }); diff --git a/tests/DatabaseTest.php b/tests/Io/DatabaseTest.php similarity index 88% rename from tests/DatabaseTest.php rename to tests/Io/DatabaseTest.php index f92c459..a8c93ad 100644 --- a/tests/DatabaseTest.php +++ b/tests/Io/DatabaseTest.php @@ -1,7 +1,7 @@ stdin = $stdin; $process->stdout = $stdout; - /* @var Database $database */ - $ref = new ReflectionClass('Clue\React\SQLite\Database'); + /* @var DatabaseInterface $database */ + $ref = new ReflectionClass('Clue\React\SQLite\Io\ProcessIoDatabase'); $database = $ref->newInstanceWithoutConstructor(); $ref = new ReflectionMethod($database, '__construct'); @@ -42,8 +42,8 @@ public function testDatabaseWillEmitErrorWhenStdoutReportsNdjsonButNotJsonRpcStr $process->stdin = $stdin; $process->stdout = $stdout; - /* @var Database $database */ - $ref = new ReflectionClass('Clue\React\SQLite\Database'); + /* @var DatabaseInterface $database */ + $ref = new ReflectionClass('Clue\React\SQLite\Io\ProcessIoDatabase'); $database = $ref->newInstanceWithoutConstructor(); $ref = new ReflectionMethod($database, '__construct'); @@ -65,8 +65,8 @@ public function testExecWillWriteExecMessageToProcessAndReturnPromise() $process = $this->getMockBuilder('React\ChildProcess\Process')->disableOriginalConstructor()->getMock(); $process->stdin = $stdin; - /* @var Database $database */ - $ref = new ReflectionClass('Clue\React\SQLite\Database'); + /* @var DatabaseInterface $database */ + $ref = new ReflectionClass('Clue\React\SQLite\Io\ProcessIoDatabase'); $database = $ref->newInstanceWithoutConstructor(); $ref = new ReflectionProperty($database, 'process'); @@ -87,8 +87,8 @@ public function testQueryWillWriteQueryMessageToProcessAndReturnPromise() $process = $this->getMockBuilder('React\ChildProcess\Process')->disableOriginalConstructor()->getMock(); $process->stdin = $stdin; - /* @var Database $database */ - $ref = new ReflectionClass('Clue\React\SQLite\Database'); + /* @var DatabaseInterface $database */ + $ref = new ReflectionClass('Clue\React\SQLite\Io\ProcessIoDatabase'); $database = $ref->newInstanceWithoutConstructor(); $ref = new ReflectionProperty($database, 'process'); @@ -110,8 +110,8 @@ public function testQuitWillWriteCloseMessageToProcessAndEndInputAndReturnPromis $process = $this->getMockBuilder('React\ChildProcess\Process')->disableOriginalConstructor()->getMock(); $process->stdin = $stdin; - /* @var Database $database */ - $ref = new ReflectionClass('Clue\React\SQLite\Database'); + /* @var DatabaseInterface $database */ + $ref = new ReflectionClass('Clue\React\SQLite\Io\ProcessIoDatabase'); $database = $ref->newInstanceWithoutConstructor(); $ref = new ReflectionProperty($database, 'process'); @@ -132,8 +132,8 @@ public function testQuitWillRejectPromiseWhenStdinAlreadyClosed() $process = $this->getMockBuilder('React\ChildProcess\Process')->disableOriginalConstructor()->getMock(); $process->stdin = $stdin; - /* @var Database $database */ - $ref = new ReflectionClass('Clue\React\SQLite\Database'); + /* @var DatabaseInterface $database */ + $ref = new ReflectionClass('Clue\React\SQLite\Io\ProcessIoDatabase'); $database = $ref->newInstanceWithoutConstructor(); $ref = new ReflectionProperty($database, 'process'); @@ -157,8 +157,8 @@ public function testCloseWillCloseStreamsAndTerminateProcess() $process->expects($this->once())->method('terminate'); $process->pipes = array($stdin, $stdout); - /* @var Database $database */ - $ref = new ReflectionClass('Clue\React\SQLite\Database'); + /* @var DatabaseInterface $database */ + $ref = new ReflectionClass('Clue\React\SQLite\Io\ProcessIoDatabase'); $database = $ref->newInstanceWithoutConstructor(); $ref = new ReflectionProperty($database, 'process'); @@ -181,8 +181,8 @@ public function testCloseTwiceWillCloseStreamsAndTerminateProcessOnce() $process->expects($this->once())->method('terminate'); $process->pipes = array($stdin, $stdout); - /* @var Database $database */ - $ref = new ReflectionClass('Clue\React\SQLite\Database'); + /* @var DatabaseInterface $database */ + $ref = new ReflectionClass('Clue\React\SQLite\Io\ProcessIoDatabase'); $database = $ref->newInstanceWithoutConstructor(); $ref = new ReflectionProperty($database, 'process');