diff --git a/README.md b/README.md index 11fb6d8..cd413f6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ built on top of [ReactPHP](https://reactphp.org/). * [Usage](#usage) * [Factory](#factory) * [open()](#open) + * [openLazy()](#openlazy) * [DatabaseInterface](#databaseinterface) * [exec()](#exec) * [query()](#query) @@ -31,24 +32,18 @@ existing SQLite database file (or automatically create it on first run) and then $loop = React\EventLoop\Factory::create(); $factory = new Clue\React\SQLite\Factory($loop); +$db = $factory->openLazy('users.db'); +$db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); + $name = 'Alice'; -$factory->open('users.db')->then( - 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( - function (Clue\React\SQLite\Result $result) use ($name) { - echo 'New ID for ' . $name . ': ' . $result->insertId . PHP_EOL; - } - ); - - $db->quit(); - }, - function (Exception $e) { - echo 'Error: ' . $e->getMessage() . PHP_EOL; +$db->query('INSERT INTO foo (bar) VALUES (?)', [$name])->then( + function (Clue\React\SQLite\Result $result) use ($name) { + echo 'New ID for ' . $name . ': ' . $result->insertId . PHP_EOL; } ); +$db->quit(); + $loop->run(); ``` @@ -101,6 +96,75 @@ $factory->open('users.db', SQLITE3_OPEN_READONLY)->then(function (DatabaseInterf }); ``` +#### openLazy() + +The `openLazy(string $filename, int $flags = null, array $options = []): DatabaseInterface` method can be used to +open a new database connection for the given SQLite database file. + +```php +$db = $factory->openLazy('users.db'); + +$db->query('INSERT INTO users (name) VALUES ("test")'); +$db->quit(); +``` + +This method immediately returns a "virtual" connection implementing the +[`DatabaseInterface`](#databaseinterface) that can be used to +interface with your SQLite database. Internally, it lazily creates the +underlying database process only on demand once the first request is +invoked on this instance and will queue all outstanding requests until +the underlying database is ready. Additionally, it will only keep this +underlying database in an "idle" state for 60s by default and will +automatically end the underlying database when it is no longer needed. + +From a consumer side this means that you can start sending queries to the +database right away while the underlying database process may still be +outstanding. Because creating this underlying process may take some +time, it will enqueue all oustanding commands and will ensure that all +commands will be executed in correct order once the database is ready. +In other words, this "virtual" database behaves just like a "real" +database as described in the `DatabaseInterface` and frees you from +having to deal with its async resolution. + +If the underlying database process fails, it will reject all +outstanding commands and will return to the initial "idle" state. This +means that you can keep sending additional commands at a later time which +will again try to open a new underlying database. Note that this may +require special care if you're using transactions that are kept open for +longer than the idle period. + +Note that creating the underlying database will be deferred until the +first request is invoked. Accordingly, any eventual connection issues +will be detected once this instance is first used. You can use the +`quit()` method to ensure that the "virtual" connection will be soft-closed +and no further commands can be enqueued. Similarly, calling `quit()` on +this instance when not currently connected will succeed immediately and +will not have to wait for an actual underlying connection. + +Depending on your particular use case, you may prefer this method or the +underlying `open()` method which resolves with a promise. For many +simple use cases it may be easier to create a lazy connection. + +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 +$db = $factory->openLazy('users.db', SQLITE3_OPEN_READONLY); +``` + +By default, this method will keep "idle" connection open for 60s and will +then end the underlying connection. The next request after an "idle" +connection ended will automatically create a new underlying connection. +This ensure you always get a "fresh" connection and as such should not be +confused with a "keepalive" or "heartbeat" mechanism, as this will not +actively try to probe the connection. You can explicitly pass a custom +idle timeout value in seconds (or use a negative number to not apply a +timeout) like this: + +```php +$db = $factory->openLazy('users.db', null, ['idle' => 0.1]); +``` + ### DatabaseInterface The `DatabaseInterface` represents a connection that is responsible for @@ -149,7 +213,7 @@ method instead. #### query() -The `query(string $query, array $params = array()): PromiseInterface` method can be used to +The `query(string $query, array $params = []): PromiseInterface` method can be used to perform an async query. diff --git a/examples/insert.php b/examples/insert.php index 706a173..c8b01b6 100644 --- a/examples/insert.php +++ b/examples/insert.php @@ -1,6 +1,5 @@ open('test.db')->then(function (DatabaseInterface $db) use ($n) { - $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); +$db = $factory->openLazy('test.db'); - for ($i = 0; $i < $n; ++$i) { - $db->exec("INSERT INTO foo (bar) VALUES ('This is a test')")->then(function (Result $result) { - echo 'New row ' . $result->insertId . PHP_EOL; - }); - } +$promise = $db->exec('CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY AUTOINCREMENT, bar STRING)'); +$promise->then(null, 'printf'); - $db->quit(); -}, 'printf'); +for ($i = 0; $i < $n; ++$i) { + $db->exec("INSERT INTO foo (bar) VALUES ('This is a test')")->then(function (Result $result) { + echo 'New row ' . $result->insertId . PHP_EOL; + }); +} + +$db->quit(); $loop->run(); diff --git a/examples/search.php b/examples/search.php index e8a7a97..39466a0 100644 --- a/examples/search.php +++ b/examples/search.php @@ -10,15 +10,15 @@ $factory = new Factory($loop); $search = isset($argv[1]) ? $argv[1] : 'foo'; -$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; - foreach ($result->rows as $row) { - echo implode("\t", $row) . PHP_EOL; - } - }, 'printf'); - $db->quit(); +$db = $factory->openLazy('test.db'); + +$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; + foreach ($result->rows as $row) { + echo implode("\t", $row) . PHP_EOL; + } }, 'printf'); +$db->quit(); $loop->run(); diff --git a/src/Factory.php b/src/Factory.php index d888fb6..ad5c7f8 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -2,12 +2,12 @@ namespace Clue\React\SQLite; +use Clue\React\SQLite\Io\LazyDatabase; +use Clue\React\SQLite\Io\ProcessIoDatabase; use React\ChildProcess\Process; use React\EventLoop\LoopInterface; -use Clue\React\SQLite\Io\ProcessIoDatabase; -use React\Stream\DuplexResourceStream; use React\Promise\Deferred; -use React\Stream\ThroughStream; +use React\Stream\DuplexResourceStream; class Factory { @@ -88,6 +88,83 @@ public function open($filename, $flags = null) return $this->useSocket ? $this->openSocketIo($filename, $flags) : $this->openProcessIo($filename, $flags); } + /** + * Opens a new database connection for the given SQLite database file. + * + * ```php + * $db = $factory->openLazy('users.db'); + * + * $db->query('INSERT INTO users (name) VALUES ("test")'); + * $db->quit(); + * ``` + * + * This method immediately returns a "virtual" connection implementing the + * [`DatabaseInterface`](#databaseinterface) that can be used to + * interface with your SQLite database. Internally, it lazily creates the + * underlying database process only on demand once the first request is + * invoked on this instance and will queue all outstanding requests until + * the underlying database is ready. Additionally, it will only keep this + * underlying database in an "idle" state for 60s by default and will + * automatically end the underlying database when it is no longer needed. + * + * From a consumer side this means that you can start sending queries to the + * database right away while the underlying database process may still be + * outstanding. Because creating this underlying process may take some + * time, it will enqueue all oustanding commands and will ensure that all + * commands will be executed in correct order once the database is ready. + * In other words, this "virtual" database behaves just like a "real" + * database as described in the `DatabaseInterface` and frees you from + * having to deal with its async resolution. + * + * If the underlying database process fails, it will reject all + * outstanding commands and will return to the initial "idle" state. This + * means that you can keep sending additional commands at a later time which + * will again try to open a new underlying database. Note that this may + * require special care if you're using transactions that are kept open for + * longer than the idle period. + * + * Note that creating the underlying database will be deferred until the + * first request is invoked. Accordingly, any eventual connection issues + * will be detected once this instance is first used. You can use the + * `quit()` method to ensure that the "virtual" connection will be soft-closed + * and no further commands can be enqueued. Similarly, calling `quit()` on + * this instance when not currently connected will succeed immediately and + * will not have to wait for an actual underlying connection. + * + * Depending on your particular use case, you may prefer this method or the + * underlying `open()` method which resolves with a promise. For many + * simple use cases it may be easier to create a lazy connection. + * + * 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 + * $db = $factory->openLazy('users.db', SQLITE3_OPEN_READONLY); + * ``` + * + * By default, this method will keep "idle" connection open for 60s and will + * then end the underlying connection. The next request after an "idle" + * connection ended will automatically create a new underlying connection. + * This ensure you always get a "fresh" connection and as such should not be + * confused with a "keepalive" or "heartbeat" mechanism, as this will not + * actively try to probe the connection. You can explicitly pass a custom + * idle timeout value in seconds (or use a negative number to not apply a + * timeout) like this: + * + * ```php + * $db = $factory->openLazy('users.db', null, ['idle' => 0.1]); + * ``` + * + * @param string $filename + * @param ?int $flags + * @param array $options + * @return DatabaseInterface + */ + public function openLazy($filename, $flags = null, array $options = []) + { + return new LazyDatabase($filename, $flags, $options, $this, $this->loop); + } + private function openProcessIo($filename, $flags = null) { $command = 'exec ' . \escapeshellarg($this->bin) . ' sqlite-worker.php'; diff --git a/src/Io/LazyDatabase.php b/src/Io/LazyDatabase.php new file mode 100644 index 0000000..7beb1fe --- /dev/null +++ b/src/Io/LazyDatabase.php @@ -0,0 +1,199 @@ +filename = $target; + $this->flags = $flags; + $this->factory = $factory; + $this->loop = $loop; + + if (isset($options['idle'])) { + $this->idlePeriod = (float)$options['idle']; + } + } + + /** + * @return \React\Promise\PromiseInterface + */ + private function db() + { + if ($this->promise !== null) { + return $this->promise; + } + + if ($this->closed) { + return \React\Promise\reject(new \RuntimeException('Connection closed')); + } + + // force-close connection if still waiting for previous disconnection + if ($this->disconnecting !== null) { + $this->disconnecting->close(); + $this->disconnecting = null; + } + + $this->promise = $promise = $this->factory->open($this->filename, $this->flags); + $promise->then(function (DatabaseInterface $db) { + // connection completed => remember only until closed + $db->on('close', function () { + $this->promise = null; + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + }); + }, function () { + // connection failed => discard connection attempt + $this->promise = null; + }); + + return $promise; + } + + public function exec($sql) + { + return $this->db()->then(function (DatabaseInterface $db) use ($sql) { + $this->awake(); + return $db->exec($sql)->then( + function ($result) { + $this->idle(); + return $result; + }, + function ($error) { + $this->idle(); + throw $error; + } + ); + }); + } + + public function query($sql, array $params = []) + { + return $this->db()->then(function (DatabaseInterface $db) use ($sql, $params) { + $this->awake(); + return $db->query($sql, $params)->then( + function ($result) { + $this->idle(); + return $result; + }, + function ($error) { + $this->idle(); + throw $error; + } + ); + }); + } + + public function quit() + { + if ($this->promise === null && !$this->closed) { + $this->close(); + return \React\Promise\resolve(); + } + + return $this->db()->then(function (DatabaseInterface $db) { + $db->on('close', function () { + $this->close(); + }); + return $db->quit(); + }); + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + + // force-close connection if still waiting for previous disconnection + if ($this->disconnecting !== null) { + $this->disconnecting->close(); + $this->disconnecting = null; + } + + // either close active connection or cancel pending connection attempt + if ($this->promise !== null) { + $this->promise->then(function (DatabaseInterface $db) { + $db->close(); + }); + if ($this->promise !== null) { + $this->promise->cancel(); + $this->promise = null; + } + } + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + + $this->emit('close'); + $this->removeAllListeners(); + } + + private function awake() + { + ++$this->pending; + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + } + + private function idle() + { + --$this->pending; + + if ($this->pending < 1 && $this->idlePeriod >= 0) { + $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () { + $this->promise->then(function (DatabaseInterface $db) { + $this->disconnecting = $db; + $db->quit()->then( + function () { + // successfully disconnected => remove reference + $this->disconnecting = null; + }, + function () use ($db) { + // soft-close failed => force-close connection + $db->close(); + $this->disconnecting = null; + } + ); + }); + $this->promise = null; + $this->idleTimer = null; + }); + } + } +} diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php new file mode 100644 index 0000000..66fa5e2 --- /dev/null +++ b/tests/FactoryTest.php @@ -0,0 +1,31 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $factory = new Factory($loop); + + $db = $factory->openLazy(':memory:'); + + $this->assertInstanceOf('Clue\React\SQLite\DatabaseInterface', $db); + } + + public function testLoadLazyWithIdleOptionsReturnsDatabaseWithIdleTimeApplied() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $factory = new Factory($loop); + + $db = $factory->openLazy(':memory:', null, ['idle' => 10.0]); + + $ref = new ReflectionProperty($db, 'idlePeriod'); + $ref->setAccessible(true); + $value = $ref->getValue($db); + + $this->assertEquals(10.0, $value); + } +} diff --git a/tests/Io/LazyDatabaseTest.php b/tests/Io/LazyDatabaseTest.php new file mode 100644 index 0000000..24e6b7a --- /dev/null +++ b/tests/Io/LazyDatabaseTest.php @@ -0,0 +1,759 @@ +factory = $this->getMockBuilder('Clue\React\SQLite\Factory')->disableOriginalConstructor()->getMock(); + $this->loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $this->db = new LazyDatabase('localhost', null, [], $this->factory, $this->loop); + } + + public function testExecWillCreateUnderlyingDatabaseAndReturnPendingPromise() + { + $promise = new Promise(function () { }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + + $promise->then($this->expectCallableNever()); + } + + public function testExecTwiceWillCreateOnceUnderlyingDatabase() + { + $promise = new Promise(function () { }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->db->exec('CREATE'); + $this->db->exec('CREATE'); + } + + public function testExecWillRejectWhenCreateUnderlyingDatabaseRejects() + { + $ex = new \RuntimeException(); + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\reject($ex)); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + $promise->then(null, $this->expectCallableOnceWith($ex)); + } + + public function testExecAgainAfterPreviousExecRejectedBecauseCreateUnderlyingDatabaseRejectsWillTryToOpenDatabaseAgain() + { + $ex = new \RuntimeException(); + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + \React\Promise\reject($ex), + new Promise(function () { }) + ); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + $promise->then(null, $this->expectCallableOnceWith($ex)); + + $this->db->exec('CREATE'); + } + + public function testExecWillResolveWhenUnderlyingDatabaseResolvesExecAndStartIdleTimer() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->with('CREATE')->willReturn(\React\Promise\resolve('PONG')); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer')->with(60, $this->anything()); + + $promise = $this->db->exec('CREATE'); + $deferred->resolve($client); + + $promise->then($this->expectCallableOnceWith('PONG')); + } + + public function testExecWillResolveWhenUnderlyingDatabaseResolvesExecAndStartIdleTimerWithIdleTimeFromOptions() + { + $this->db = new LazyDatabase(':memory:', null, ['idle' => 10.0], $this->factory, $this->loop); + + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->with('CREATE')->willReturn(\React\Promise\resolve('PONG')); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer')->with(10.0, $this->anything()); + + $promise = $this->db->exec('CREATE'); + $deferred->resolve($client); + + $promise->then($this->expectCallableOnceWith('PONG')); + } + + public function testExecWillResolveWhenUnderlyingDatabaseResolvesExecAndNotStartIdleTimerWhenIdleOptionIsNegative() + { + $this->db = new LazyDatabase(':memory:', null, ['idle' => -1], $this->factory, $this->loop); + + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->with('CREATE')->willReturn(\React\Promise\resolve('PONG')); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + $deferred->resolve($client); + + $promise->then($this->expectCallableOnceWith('PONG')); + } + + public function testExecWillRejectWhenUnderlyingDatabaseRejectsExecAndStartIdleTimer() + { + $error = new \RuntimeException(); + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->with('CREATE')->willReturn(\React\Promise\reject($error)); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer'); + + $promise = $this->db->exec('CREATE'); + $deferred->resolve($client); + + $promise->then(null, $this->expectCallableOnceWith($error)); + } + + public function testExecWillRejectAndNotEmitErrorOrCloseWhenFactoryRejectsUnderlyingDatabase() + { + $error = new \RuntimeException(); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->on('error', $this->expectCallableNever()); + $this->db->on('close', $this->expectCallableNever()); + + $promise = $this->db->exec('CREATE'); + $deferred->reject($error); + + $promise->then(null, $this->expectCallableOnceWith($error)); + } + + public function testExecAfterPreviousFactoryRejectsUnderlyingDatabaseWillCreateNewUnderlyingConnection() + { + $error = new \RuntimeException(); + + $deferred = new Deferred(); + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->db->exec('CREATE'); + $deferred->reject($error); + + $this->db->exec('CREATE'); + } + + public function testExecAfterPreviousUnderlyingDatabaseAlreadyClosedWillCreateNewUnderlyingConnection() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve('PONG')); + + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + \React\Promise\resolve($client), + new Promise(function () { }) + ); + + $this->db->exec('CREATE'); + $client->emit('close'); + + $this->db->exec('CREATE'); + } + + public function testExecAfterCloseWillRejectWithoutCreatingUnderlyingConnection() + { + $this->factory->expects($this->never())->method('open'); + + $this->db->close(); + $promise = $this->db->exec('CREATE'); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testExecAfterExecWillNotStartIdleTimerWhenFirstExecResolves() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->exactly(2))->method('exec')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $this->loop->expects($this->never())->method('addTimer'); + + $this->db->exec('CREATE'); + $this->db->exec('CREATE'); + $deferred->resolve(); + } + + public function testExecAfterExecWillStartAndCancelIdleTimerWhenSecondExecStartsAfterFirstResolves() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->exactly(2))->method('exec')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $this->db->exec('CREATE'); + $deferred->resolve(); + $this->db->exec('CREATE'); + } + + public function testExecFollowedByIdleTimerWillQuitUnderlyingConnectionWithoutCloseEvent() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'quit', 'close'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + $client->expects($this->never())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->exec('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + } + + public function testExecFollowedByIdleTimerWillCloseUnderlyingConnectionWhenQuitFails() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->setMethods(array('exec', 'quit', 'close'))->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(\React\Promise\reject()); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->exec('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + } + + public function testExecAfterIdleTimerWillCloseUnderlyingConnectionBeforeCreatingSecondConnection() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->setMethods(array('exec', 'quit', 'close'))->disableOriginalConstructor()->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + \React\Promise\resolve($client), + new Promise(function () { }) + ); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->exec('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + + $this->db->exec('CREATE'); + } + + public function testQueryWillCreateUnderlyingDatabaseAndReturnPendingPromise() + { + $promise = new Promise(function () { }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->loop->expects($this->never())->method('addTimer'); + + $promise = $this->db->query('CREATE'); + + $promise->then($this->expectCallableNever()); + } + + public function testQueryTwiceWillCreateOnceUnderlyingDatabase() + { + $promise = new Promise(function () { }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->db->query('CREATE'); + $this->db->query('CREATE'); + } + + public function testQueryWillResolveWhenUnderlyingDatabaseResolvesQueryAndStartIdleTimer() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('query')->with('SELECT :id', ['id' => 42])->willReturn(\React\Promise\resolve('PONG')); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer')->with(60.0, $this->anything()); + + $promise = $this->db->query('SELECT :id', ['id' => 42]); + $deferred->resolve($client); + + $promise->then($this->expectCallableOnceWith('PONG')); + } + + public function testQueryWillRejectWhenUnderlyingDatabaseRejectsQueryAndStartIdleTimer() + { + $error = new \RuntimeException(); + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('query')->with('CREATE')->willReturn(\React\Promise\reject($error)); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->loop->expects($this->once())->method('addTimer'); + + $promise = $this->db->query('CREATE'); + $deferred->resolve($client); + + $promise->then(null, $this->expectCallableOnceWith($error)); + } + + public function testQueryWillRejectAndNotEmitErrorOrCloseWhenFactoryRejectsUnderlyingDatabase() + { + $error = new \RuntimeException(); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->on('error', $this->expectCallableNever()); + $this->db->on('close', $this->expectCallableNever()); + + $promise = $this->db->query('CREATE'); + $deferred->reject($error); + + $promise->then(null, $this->expectCallableOnceWith($error)); + } + + public function testQueryAfterPreviousFactoryRejectsUnderlyingDatabaseWillCreateNewUnderlyingConnection() + { + $error = new \RuntimeException(); + + $deferred = new Deferred(); + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->db->query('CREATE'); + $deferred->reject($error); + + $this->db->query('CREATE'); + } + + public function testQueryAfterPreviousUnderlyingDatabaseAlreadyClosedWillCreateNewUnderlyingConnection() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query'))->getMock(); + $client->expects($this->once())->method('query')->willReturn(\React\Promise\resolve('PONG')); + + $this->factory->expects($this->exactly(2))->method('open')->willReturnOnConsecutiveCalls( + \React\Promise\resolve($client), + new Promise(function () { }) + ); + + $this->db->query('CREATE'); + $client->emit('close'); + + $this->db->query('CREATE'); + } + + public function testQueryAfterCloseWillRejectWithoutCreatingUnderlyingConnection() + { + $this->factory->expects($this->never())->method('open'); + + $this->db->close(); + $promise = $this->db->query('CREATE'); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQueryAfterQueryWillNotStartIdleTimerWhenFirstQueryResolves() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query'))->getMock(); + $client->expects($this->exactly(2))->method('query')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $this->loop->expects($this->never())->method('addTimer'); + + $this->db->query('CREATE'); + $this->db->query('CREATE'); + $deferred->resolve(); + } + + public function testQueryAfterQueryWillStartAndCancelIdleTimerWhenSecondQueryStartsAfterFirstResolves() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query'))->getMock(); + $client->expects($this->exactly(2))->method('query')->willReturnOnConsecutiveCalls( + $deferred->promise(), + new Promise(function () { }) + ); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $this->db->query('CREATE'); + $deferred->resolve(); + $this->db->query('CREATE'); + } + + public function testQueryFollowedByIdleTimerWillQuitUnderlyingConnectionWithoutCloseEvent() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('query', 'quit', 'close'))->getMock(); + $client->expects($this->once())->method('query')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + $client->expects($this->never())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->query('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + } + + public function testCloseWillEmitCloseEventWithoutCreatingUnderlyingDatabase() + { + $this->factory->expects($this->never())->method('open'); + + $this->db->on('close', $this->expectCallableOnce()); + + $this->db->close(); + } + + public function testCloseTwiceWillEmitCloseEventOnce() + { + $this->db->on('close', $this->expectCallableOnce()); + + $this->db->close(); + $this->db->close(); + } + + public function testCloseAfterExecWillCancelUnderlyingDatabaseConnectionWhenStillPending() + { + $promise = new Promise(function () { }, $this->expectCallableOnce()); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->db->exec('CREATE'); + $this->db->close(); + } + + public function testCloseAfterExecWillEmitCloseWithoutErrorWhenUnderlyingDatabaseConnectionThrowsDueToCancellation() + { + $promise = new Promise(function () { }, function () { + throw new \RuntimeException('Discarded'); + }); + $this->factory->expects($this->once())->method('open')->willReturn($promise); + + $this->db->on('error', $this->expectCallableNever()); + $this->db->on('close', $this->expectCallableOnce()); + + $this->db->exec('CREATE'); + $this->db->close(); + } + + public function testCloseAfterExecWillCloseUnderlyingDatabaseConnectionWhenAlreadyResolved() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('close'); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + $this->db->close(); + } + + public function testCloseAfterExecWillCancelIdleTimerWhenExecIsAlreadyResolved() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'close'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn($deferred->promise()); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $this->db->exec('CREATE'); + $deferred->resolve(); + $this->db->close(); + } + + public function testCloseAfterExecRejectsWillEmitClose() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'close'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn($deferred->promise()); + $client->expects($this->once())->method('close')->willReturnCallback(function () use ($client) { + $client->emit('close'); + }); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $ref = $this->db; + $ref->exec('CREATE')->then(null, function () use ($ref, $client) { + $ref->close(); + }); + $ref->on('close', $this->expectCallableOnce()); + $deferred->reject(new \RuntimeException()); + } + + public function testCloseAfterQuitAfterExecWillCloseUnderlyingConnectionWhenQuitIsStillPending() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $this->db->exec('CREATE'); + $this->db->quit(); + $this->db->close(); + } + + public function testCloseAfterExecAfterIdleTimeoutWillCloseUnderlyingConnectionWhenQuitIsStillPending() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + $client->expects($this->once())->method('quit')->willReturn(new Promise(function () { })); + $client->expects($this->once())->method('close'); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timeout = null; + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->with($this->anything(), $this->callback(function ($cb) use (&$timeout) { + $timeout = $cb; + return true; + }))->willReturn($timer); + + $this->db->exec('CREATE'); + + $this->assertNotNull($timeout); + $timeout(); + + $this->db->close(); + } + + public function testQuitWillCloseDatabaseIfUnderlyingConnectionIsNotPendingAndResolveImmediately() + { + $this->db->on('close', $this->expectCallableOnce()); + $promise = $this->db->quit(); + + $promise->then($this->expectCallableOnce()); + } + + public function testQuitAfterQuitWillReject() + { + $this->db->quit(); + $promise = $this->db->quit(); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQuitAfterExecWillQuitUnderlyingDatabase() + { + $client = $this->getMockBuilder('Clue\React\SQLite\DatabaseInterface')->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve('PONG')); + $client->expects($this->once())->method('quit'); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + $promise = $this->db->quit(); + + $promise->then($this->expectCallableOnce()); + } + + public function testQuitAfterExecWillCloseDatabaseWhenUnderlyingDatabaseEmitsClose() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec', 'quit'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve('PONG')); + $client->expects($this->once())->method('quit')->willReturn(\React\Promise\resolve()); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + + $this->db->on('close', $this->expectCallableOnce()); + $promise = $this->db->quit(); + + $client->emit('close'); + $promise->then($this->expectCallableOnce()); + } + + public function testEmitsNoErrorEventWhenUnderlyingDatabaseEmitsError() + { + $error = new \RuntimeException(); + + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + + $this->db->on('error', $this->expectCallableNever()); + $client->emit('error', array($error)); + } + + public function testEmitsNoCloseEventWhenUnderlyingDatabaseEmitsClose() + { + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn(\React\Promise\resolve()); + + $deferred = new Deferred(); + $this->factory->expects($this->once())->method('open')->willReturn($deferred->promise()); + + $this->db->exec('CREATE'); + $deferred->resolve($client); + + $this->db->on('close', $this->expectCallableNever()); + $client->emit('close'); + } + + public function testEmitsNoCloseEventButWillCancelIdleTimerWhenUnderlyingConnectionEmitsCloseAfterExecIsAlreadyResolved() + { + $deferred = new Deferred(); + $client = $this->getMockBuilder('Clue\React\SQLite\Io\ProcessIoDatabase')->disableOriginalConstructor()->setMethods(array('exec'))->getMock(); + $client->expects($this->once())->method('exec')->willReturn($deferred->promise()); + + $this->factory->expects($this->once())->method('open')->willReturn(\React\Promise\resolve($client)); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $this->loop->expects($this->once())->method('addTimer')->willReturn($timer); + $this->loop->expects($this->once())->method('cancelTimer')->with($timer); + + $this->db->on('close', $this->expectCallableNever()); + + $this->db->exec('CREATE'); + $deferred->resolve(); + + $client->emit('close'); + } + + protected function expectCallableNever() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + return $mock; + } + + protected function expectCallableOnce() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke'); + + return $mock; + } + + protected function expectCallableOnceWith($value) + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($value); + + return $mock; + } + + protected function createCallableMock() + { + return $this->getMockBuilder('stdClass')->setMethods(array('__invoke'))->getMock(); + } +} diff --git a/tests/Io/DatabaseTest.php b/tests/Io/ProcessIoDatabaseTest.php similarity index 99% rename from tests/Io/DatabaseTest.php rename to tests/Io/ProcessIoDatabaseTest.php index a8c93ad..c5a57e9 100644 --- a/tests/Io/DatabaseTest.php +++ b/tests/Io/ProcessIoDatabaseTest.php @@ -4,7 +4,7 @@ use Clue\React\SQLite\DatabaseInterface; use React\Stream\ThroughStream; -class DatabaseTest extends TestCase +class ProcessIoDatabaseTest extends TestCase { public function testDatabaseWillEmitErrorWhenStdoutReportsNonNdjsonStream() {