From f5f006b04d480b5c1c77e5808de862a67e2f20b0 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 24 Sep 2019 12:32:37 -0700 Subject: [PATCH] feat: Add Firestore session handler. (#2258) * adds Firestore session handler * Update Firestore/src/FirestoreSessionHandler.php Co-Authored-By: John Pedrie * wraps writes in transactions * removes collection name validation, mocks runTransaction better * Add snippet and system tests * remove extraneous doc lines * Skip tests if grpc missing * updates sessionhandler to use transactions * first round of test fixes * fixes syntax error * adds GC test, fixes remaining tests * fixes cs * fixes test snippets * removes unnecessary changes and adds test for coverage * adds options comment, adds collectionNameTemplate option * removes redundant optional identifier * addresses review comments * addresses more review comments * adds back variable to callables * adds try/catch around beginTransaction --- Firestore/src/FirestoreClient.php | 47 ++ Firestore/src/FirestoreSessionHandler.php | 429 ++++++++++++++++ Firestore/src/Query.php | 1 + .../Snippet/FirestoreSessionHandlerTest.php | 186 +++++++ .../System/FirestoreSessionHandlerTest.php | 83 ++++ Firestore/tests/Unit/FirestoreClientTest.php | 7 + .../Unit/FirestoreSessionHandlerTest.php | 459 ++++++++++++++++++ 7 files changed, 1212 insertions(+) create mode 100644 Firestore/src/FirestoreSessionHandler.php create mode 100644 Firestore/tests/Snippet/FirestoreSessionHandlerTest.php create mode 100644 Firestore/tests/System/FirestoreSessionHandlerTest.php create mode 100644 Firestore/tests/Unit/FirestoreSessionHandlerTest.php diff --git a/Firestore/src/FirestoreClient.php b/Firestore/src/FirestoreClient.php index a4c024370c6f..7788f137b75f 100644 --- a/Firestore/src/FirestoreClient.php +++ b/Firestore/src/FirestoreClient.php @@ -573,4 +573,51 @@ public function fieldPath(array $fieldNames) { return new FieldPath($fieldNames); } + + /** + * Returns a FirestoreSessionHandler. + * + * Example: + * ``` + * $handler = $firestore->sessionHandler(); + * + * // Configure PHP to use the Firestore session handler. + * session_set_save_handler($handler, true); + * session_save_path('sessions'); + * session_start(); + * + * // Then write and read the $_SESSION array. + * $_SESSION['name'] = 'Bob'; + * echo $_SESSION['name']; + * ``` + * + * @param array $options [optional] { + * Configuration Options. + * + * @type int $gcLimit The number of entities to delete in the garbage + * collection. Values larger than 1000 will be limited to 1000. + * **Defaults to** `0`, indicating garbage collection is disabled by + * default. + * @type string $collectionNameTemplate A sprintf compatible template + * for formatting the collection name where sessions will be stored. + * The template receives two values, the first being the save path + * and the latter being the session name. + * @type array $begin Configuration options for beginTransaction. + * @type array $commit Configuration options for commit. + * @type array $rollback Configuration options for rollback. + * @type array $read Configuration options for read. + * @type array $query Configuration options for runQuery. + * } + * @return FirestoreSessionHandler + */ + public function sessionHandler(array $options = []) + { + return new FirestoreSessionHandler( + $this->connection, + $this->valueMapper, + $this->projectId, + $this->database, + $options + ); + } } diff --git a/Firestore/src/FirestoreSessionHandler.php b/Firestore/src/FirestoreSessionHandler.php new file mode 100644 index 000000000000..609e53bd51b4 --- /dev/null +++ b/Firestore/src/FirestoreSessionHandler.php @@ -0,0 +1,429 @@ +sessionHandler(); + * + * session_set_save_handler($handler, true); + * session_save_path('sessions'); + * session_start(); + * + * // Then write and read the $_SESSION array. + * $_SESSION['name'] = 'Bob'; + * echo $_SESSION['name']; + * ``` + * + * ``` + * // Session handler with error handling: + * use Google\Cloud\Firestore\FirestoreClient; + * + * $firestore = new FirestoreClient(); + * + * $handler = $firestore->sessionHandler(); + * session_set_save_handler($handler, true); + * session_save_path('sessions'); + * session_start(); + * + * // Then read and write the $_SESSION array. + * $_SESSION['name'] = 'Bob'; + * + * function handle_session_error($errNo, $errStr, $errFile, $errLine) { + * // We throw an exception here, but you can do whatever you need. + * throw new RuntimeException( + * "$errStr in $errFile on line $errLine", + * $errNo + * ); + * } + * set_error_handler('handle_session_error', E_USER_WARNING); + * + * // If `write` fails for any reason, an exception will be thrown. + * session_write_close(); + * restore_error_handler(); + * + * // You can still read the $_SESSION array after closing the session. + * echo $_SESSION['name']; + * ``` + * + * @see http://php.net/manual/en/class.sessionhandlerinterface.php SessionHandlerInterface + */ +class FirestoreSessionHandler implements SessionHandlerInterface +{ + use SnapshotTrait; + + /** + * @var ConnectionInterface + */ + private $connection; + /** + * @var ValueMapper + */ + private $valueMapper; + /** + * @var string + */ + private $projectId; + /** + * @var string + */ + private $database; + /** + * @var array + */ + private $options; + /** + * @var string + */ + private $savePath; + /** + * @var string + */ + private $sessionName; + /** + * @var Transaction + */ + private $transaction; + + /** + * Create a custom session handler backed by Cloud Firestore. + * + * @param ConnectionInterface $connection A Connection to Cloud Firestore. + * @param ValueMapper $valueMapper A Firestore Value Mapper. + * @param string $projectId The current project id. + * @param string $database The database id. + * @param array $options [optional] { + * Configuration Options. + * + * @type int $gcLimit The number of entities to delete in the garbage + * collection. Values larger than 1000 will be limited to 1000. + * **Defaults to** `0`, indicating garbage collection is disabled by + * default. + * @type string $collectionNameTemplate A sprintf compatible template + * for formatting the collection name where sessions will be stored. + * The template receives two values, the first being the save path + * and the latter being the session name. + * @type array $begin Configuration options for beginTransaction. + * @type array $commit Configuration options for commit. + * @type array $rollback Configuration options for rollback. + * @type array $read Configuration options for read. + * @type array $query Configuration options for runQuery. + * } + */ + public function __construct( + ConnectionInterface $connection, + ValueMapper $valueMapper, + $projectId, + $database, + array $options = [] + ) { + $this->connection = $connection; + $this->valueMapper = $valueMapper; + $this->projectId = $projectId; + $this->database = $database; + $this->options = $options + [ + 'begin' => [], + 'commit' => [], + 'rollback' => [], + 'query' => [], + 'read' => [], + 'gcLimit' => 0, + 'collectionNameTemplate' => '%1$s:%2$s', + ]; + + // Cut down gcLimit to 1000 + $this->options['gcLimit'] = min($this->options['gcLimit'], 1000); + } + + /** + * Start a session, by getting the Firebase collection from $savePath. + * + * @param string $savePath The value of `session.save_path` setting will be + * used in the Firestore collection ID. + * @param string $sessionName The value of `session.name` setting will be + * used in the Firestore collection ID. + * @return bool + */ + public function open($savePath, $sessionName) + { + $this->savePath = $savePath; + $this->sessionName = $sessionName; + $database = $this->databaseName($this->projectId, $this->database); + + try { + $beginTransaction = $this->connection->beginTransaction([ + 'database' => $database + ] + $this->options['begin']); + } catch (ServiceException $e) { + trigger_error( + sprintf('Firestore beginTransaction failed: %s', $e->getMessage()), + E_USER_WARNING + ); + } + + $this->transaction = new Transaction( + $this->connection, + $this->valueMapper, + $database, + $beginTransaction['transaction'] + ); + + return true; + } + + /** + * Just return true for this implementation. + */ + public function close() + { + return true; + } + + /** + * Read the session data from Cloud Firestore. + * + * @param string $id Identifier used for the session. + * @return string + */ + public function read($id) + { + try { + $docRef = $this->getDocumentReference( + $this->connection, + $this->valueMapper, + $this->projectId, + $this->database, + $this->docId($id) + ); + $snapshot = $this->transaction->snapshot( + $docRef, + $this->options['read'] + ); + if ($snapshot->exists() && isset($snapshot['data'])) { + return $snapshot->get('data'); + } + } catch (ServiceException $e) { + trigger_error( + sprintf('Firestore lookup failed: %s', $e->getMessage()), + E_USER_WARNING + ); + } + return ''; + } + + /** + * Write the session data to Cloud Firestore. + * + * @param string $id Identifier used for the session. + * @param string $data The session data to write to the Firestore document. + * @return bool + */ + public function write($id, $data) + { + try { + $docRef = $this->getDocumentReference( + $this->connection, + $this->valueMapper, + $this->projectId, + $this->database, + $this->docId($id) + ); + $this->transaction->set($docRef, [ + 'data' => $data, + 't' => time() + ]); + $this->commitTransaction(); + } catch (ServiceException $e) { + trigger_error( + sprintf('Firestore upsert failed: %s', $e->getMessage()), + E_USER_WARNING + ); + return false; + } + return true; + } + + /** + * Delete the session data from Cloud Firestore. + * + * @param string $id Identifier used for the session + * @return bool + */ + public function destroy($id) + { + try { + $docRef = $this->getDocumentReference( + $this->connection, + $this->valueMapper, + $this->projectId, + $this->database, + $this->docId($id) + ); + $this->transaction->delete($docRef); + $this->commitTransaction(); + } catch (ServiceException $e) { + trigger_error( + sprintf('Firestore delete failed: %s', $e->getMessage()), + E_USER_WARNING + ); + return false; + } + return true; + } + + /** + * Delete the old session data from Cloud Firestore. + * + * @param int $maxlifetime Remove all session data older than this number + * in seconds. + * @return bool + */ + public function gc($maxlifetime) + { + if (0 === $this->options['gcLimit']) { + return true; + } + try { + $collectionRef = $this->getCollectionReference( + $this->connection, + $this->valueMapper, + $this->projectId, + $this->database, + $this->collectionId() + ); + $query = $collectionRef + ->limit($this->options['gcLimit']) + ->where('t', '<', time() - $maxlifetime) + ->orderBy('t'); + $querySnapshot = $this->transaction->runQuery( + $query, + $this->options['query'] + ); + foreach ($querySnapshot as $snapshot) { + $this->transaction->delete($snapshot->reference()); + } + $this->commitTransaction(); + } catch (ServiceException $e) { + trigger_error( + sprintf('Session gc failed: %s', $e->getMessage()), + E_USER_WARNING + ); + return false; + } + return true; + } + + /** + * Commit a transaction if changes exist, otherwise rollback the + * transaction. Also rollback if an exception is thrown. + * + * @throws \Exception + */ + private function commitTransaction() + { + try { + if (!$this->transaction->writer()->isEmpty()) { + $this->transaction->writer()->commit($this->options['commit']); + } else { + // trigger rollback if no writes exist. + $this->transaction->writer()->rollback($this->options['rollback']); + } + } catch (ServiceException $e) { + $this->transaction->writer()->rollback($this->options['rollback']); + + throw $e; + } + } + + /** + * Format the Firebase collection ID from the PHP session ID and session + * name according to the $collectionNameTemplate option. + * ex: sessions:PHPSESSID + * + * @param string $id Identifier used for the session + * @return string + */ + private function collectionId() + { + return sprintf( + $this->options['collectionNameTemplate'], + $this->savePath, + $this->sessionName + ); + } + + /** + * Format the Firebase document ID from the collection ID. + * ex: sessions:PHPSESSID/abcdef + * + * @param string $id Identifier used for the session + * @return string + */ + private function docId($id) + { + return sprintf('%s/%s', $this->collectionId(), $id); + } +} diff --git a/Firestore/src/Query.php b/Firestore/src/Query.php index 01823a484cb9..b7a4a1115e54 100644 --- a/Firestore/src/Query.php +++ b/Firestore/src/Query.php @@ -169,6 +169,7 @@ public function documents(array $options = []) $rows = (new ExponentialBackoff($maxRetries))->execute(function () use ($options) { $query = $this->finalQueryPrepare($this->query); + $generator = $this->connection->runQuery($this->arrayFilterRemoveNull([ 'parent' => $this->parent, 'structuredQuery' => $query, diff --git a/Firestore/tests/Snippet/FirestoreSessionHandlerTest.php b/Firestore/tests/Snippet/FirestoreSessionHandlerTest.php new file mode 100644 index 000000000000..c4049392e3e1 --- /dev/null +++ b/Firestore/tests/Snippet/FirestoreSessionHandlerTest.php @@ -0,0 +1,186 @@ +checkAndSkipGrpcTests(); + + $this->connection = $this->prophesize(ConnectionInterface::class); + $this->client = TestHelpers::stub(FirestoreClient::class); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(FirestoreSessionHandler::class); + $snippet->replace('$firestore = new FirestoreClient();', ''); + + $this->connection->batchGetDocuments(Argument::withEntry('documents', Argument::type('array'))) + ->shouldBeCalled() + ->willReturn(new \ArrayIterator([ + 'found' => [ + [ + 'name' => '', + 'fields' => [] + ] + ] + ])); + + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'transaction' => self::TRANSACTION + ]); + + $value = 'name|' . serialize('Bob'); + $this->connection->commit(Argument::allOf( + Argument::that(function ($args) use ($value) { + return strpos($args['writes'][0]['update']['name'], ':PHPSESSID') !== false + && $args['writes'][0]['update']['fields']['data']['stringValue'] === $value + && isset($args['writes'][0]['update']['fields']['t']['integerValue']); + }), + Argument::withEntry('transaction', self::TRANSACTION) + ))->shouldBeCalled()->willReturn([ + 'writeResults' => [] + ]); + + $this->client->___setProperty('connection', $this->connection->reveal()); + $snippet->addLocal('firestore', $this->client); + + $res = $snippet->invoke(); + session_write_close(); + + $this->assertEquals('Bob', $res->output()); + } + + public function testSessionHandlerMethod() + { + $snippet = $this->snippetFromMethod(FirestoreClient::class, 'sessionHandler'); + + $this->connection->batchGetDocuments(Argument::withEntry('documents', Argument::type('array'))) + ->shouldBeCalled() + ->willReturn(new \ArrayIterator([ + 'found' => [ + [ + 'name' => '', + 'fields' => [] + ] + ] + ])); + + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'transaction' => self::TRANSACTION + ]); + + $value = 'name|' . serialize('Bob'); + $this->connection->commit(Argument::allOf( + Argument::that(function ($args) use ($value) { + return strpos($args['writes'][0]['update']['name'], ':PHPSESSID') !== false + && $args['writes'][0]['update']['fields']['data']['stringValue'] === $value + && isset($args['writes'][0]['update']['fields']['t']['integerValue']); + }), + Argument::withEntry('transaction', self::TRANSACTION) + ))->shouldBeCalled()->willReturn([ + 'writeResults' => [] + ]); + + $this->client->___setProperty('connection', $this->connection->reveal()); + $snippet->addLocal('firestore', $this->client); + + $res = $snippet->invoke(); + session_write_close(); + + $this->assertEquals('Bob', $res->output()); + } + + /** + * @expectedException \RuntimeException + */ + public function testClassErrorHandler() + { + $snippet = $this->snippetFromClass(FirestoreSessionHandler::class, 1); + $snippet->replace('$firestore = new FirestoreClient();', ''); + + $this->connection->batchGetDocuments(Argument::any()) + ->shouldBeCalled() + ->willReturn(new \ArrayIterator([ + 'found' => [ + [ + 'name' => '', + 'fields' => [] + ] + ] + ])); + + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'transaction' => self::TRANSACTION + ]); + + $this->connection->commit(Argument::any()) + ->shouldBeCalled() + ->will(function () { + trigger_error('oops!', E_USER_WARNING); + }); + + $this->client->___setProperty('connection', $this->connection->reveal()); + $snippet->addLocal('firestore', $this->client); + + $res = $snippet->invoke(); + + $this->assertEquals('Bob', $res->output()); + } +} diff --git a/Firestore/tests/System/FirestoreSessionHandlerTest.php b/Firestore/tests/System/FirestoreSessionHandlerTest.php new file mode 100644 index 000000000000..07b19f3e5aea --- /dev/null +++ b/Firestore/tests/System/FirestoreSessionHandlerTest.php @@ -0,0 +1,83 @@ +sessionHandler(); + + session_set_save_handler($handler, true); + session_save_path($namespace); + session_start(); + + $sessionId = session_id(); + $_SESSION['name'] = $content; + + session_write_close(); + sleep(1); + + $hasDocument = false; + $query = $client->collection($namespace . ':' . session_name()); + foreach ($query->documents() as $snapshot) { + self::$localDeletionQueue->add($snapshot->reference()); + if (!$hasDocument) { + $hasDocument = $snapshot['data'] === $storedValue; + } + } + + $this->assertTrue($hasDocument); + } + + public function testSessionHandlerGarbageCollection() + { + $client = self::$client; + + $namespace = uniqid('sess-' . self::COLLECTION_NAME); + $sessionName = 'PHPSESSID'; + $collection = $client->collection($namespace . ':' . $sessionName); + $collection->document('foo1')->set(['data' => 'foo1', 't' => time() - 1]); + $collection->document('foo2')->set(['data' => 'foo2', 't' => time() - 1]); + + $this->assertCount(2, $collection->documents()); + + $handler = $client->sessionHandler([ + 'gcLimit' => 1000, + 'query' => ['maxRetries' => 0] + ]); + $handler->open($namespace, $sessionName); + $handler->gc(0); + + $this->assertCount(0, $collection->documents()); + } +} diff --git a/Firestore/tests/Unit/FirestoreClientTest.php b/Firestore/tests/Unit/FirestoreClientTest.php index 45b8b1a7ecfb..2295d2451e37 100644 --- a/Firestore/tests/Unit/FirestoreClientTest.php +++ b/Firestore/tests/Unit/FirestoreClientTest.php @@ -29,6 +29,7 @@ use Google\Cloud\Firestore\DocumentReference; use Google\Cloud\Firestore\FieldPath; use Google\Cloud\Firestore\FirestoreClient; +use Google\Cloud\Firestore\FirestoreSessionHandler; use Google\Cloud\Firestore\Query; use Google\Cloud\Firestore\WriteBatch; use PHPUnit\Framework\TestCase; @@ -555,6 +556,12 @@ public function testFieldPath() $this->assertEquals($parts, $path->path()); } + public function testSessionHandler() + { + $sessionHandler = $this->client->sessionHandler(); + $this->assertInstanceOf(FirestoreSessionHandler::class, $sessionHandler); + } + private function noop() { return function () { diff --git a/Firestore/tests/Unit/FirestoreSessionHandlerTest.php b/Firestore/tests/Unit/FirestoreSessionHandlerTest.php new file mode 100644 index 000000000000..f39761631776 --- /dev/null +++ b/Firestore/tests/Unit/FirestoreSessionHandlerTest.php @@ -0,0 +1,459 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->valueMapper = $this->prophesize(ValueMapper::class); + $this->documents = $this->prophesize(Iterator::class); + } + + public function testOpen() + { + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $ret = $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $this->assertTrue($ret); + } + + /** + * @expectedException PHPUnit_Framework_Error_Warning + */ + public function testOpenWithException() + { + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1) + ->willThrow(new ServiceException('')); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $ret = $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $this->assertFalse($ret); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testReadNotAllowed() + { + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $firestoreSessionHandler->open('invalid/savepath', self::SESSION_NAME); + $firestoreSessionHandler->read('sessionid'); + } + + public function testClose() + { + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $ret = $firestoreSessionHandler->close(); + $this->assertTrue($ret); + } + + public function testReadNothing() + { + $this->documents->current() + ->shouldBeCalledTimes(1) + ->willReturn(null); + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1); + $this->connection->batchGetDocuments([ + 'database' => $this->dbName(), + 'documents' => [$this->documentName()], + 'transaction' => null, + ]) + ->shouldBeCalledTimes(1) + ->willReturn($this->documents->reveal()); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->read('sessionid'); + + $this->assertEquals('', $ret); + } + + /** + * @expectedException PHPUnit_Framework_Error_Warning + */ + public function testReadWithException() + { + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1); + $this->connection->batchGetDocuments([ + 'database' => $this->dbName(), + 'documents' => [$this->documentName()], + 'transaction' => null, + ]) + ->shouldBeCalledTimes(1) + ->willThrow((new ServiceException(''))); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->read('sessionid'); + + $this->assertEquals('', $ret); + } + + public function testReadEntity() + { + $this->documents->current() + ->shouldBeCalledTimes(1) + ->willReturn([ + 'found' => [ + 'createTime' => date('Y-m-d'), + 'updateTime' => date('Y-m-d'), + 'readTime' => date('Y-m-d'), + 'fields' => ['data' => 'sessiondata'] + ] + ]); + $this->valueMapper->decodeValues(['data' => 'sessiondata']) + ->shouldBeCalledTimes(1) + ->willReturn(['data' => 'sessiondata']); + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1); + $this->connection->batchGetDocuments([ + 'database' => $this->dbName(), + 'documents' => [$this->documentName()], + 'transaction' => null, + ]) + ->shouldBeCalledTimes(1) + ->willReturn($this->documents->reveal()); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->read('sessionid'); + + $this->assertEquals('sessiondata', $ret); + } + + public function testWrite() + { + $phpunit = $this; + $this->valueMapper->encodeValues(Argument::type('array')) + ->will(function ($args) use ($phpunit) { + $phpunit->assertEquals('sessiondata', $args[0]['data']); + $phpunit->assertTrue(is_int($args[0]['t'])); + return ['data' => ['stringValue' => 'sessiondata']]; + }); + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1); + $this->connection->commit([ + 'database' => $this->dbName(), + 'writes' => [ + [ + 'update' => [ + 'name' => $this->documentName(), + 'fields' => [ + 'data' => ['stringValue' => 'sessiondata'] + ] + ] + ] + ] + ]) + ->shouldBeCalledTimes(1); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->write('sessionid', 'sessiondata'); + + $this->assertTrue($ret); + } + + /** + * @expectedException PHPUnit_Framework_Error_Warning + */ + public function testWriteWithException() + { + $phpunit = $this; + $this->valueMapper->encodeValues(Argument::type('array')) + ->will(function ($args) use ($phpunit) { + $phpunit->assertEquals('sessiondata', $args[0]['data']); + $phpunit->assertTrue(is_int($args[0]['t'])); + return ['data' => ['stringValue' => 'sessiondata']]; + }); + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1) + ->willReturn(['transaction' => 123]); + $this->connection->rollback([ + 'database' => $this->dbName(), + 'transaction' => 123 + ]) + ->shouldBeCalledTimes(1); + $this->connection->commit(Argument::any()) + ->shouldBeCalledTimes(1) + ->willThrow((new ServiceException(''))); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->write('sessionid', 'sessiondata'); + + $this->assertFalse($ret); + } + + public function testDestroy() + { + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1) + ->willReturn(['transaction' => 123]); + $this->connection->commit([ + 'database' => $this->dbName(), + 'writes' => [ + [ + 'delete' => $this->documentName() + ] + ], + 'transaction' => 123 + ]) + ->shouldBeCalledTimes(1); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->destroy('sessionid'); + + $this->assertTrue($ret); + } + + /** + * @expectedException PHPUnit_Framework_Error_Warning + */ + public function testDestroyWithException() + { + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1) + ->willReturn(['transaction' => 123]); + $this->connection->commit(Argument::any()) + ->shouldBeCalledTimes(1) + ->willThrow(new ServiceException('')); + $this->connection->rollback([ + 'database' => $this->dbName(), + 'transaction' => 123 + ]) + ->shouldBeCalledTimes(1); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->destroy('sessionid'); + + $this->assertFalse($ret); + } + + public function testDefaultGcDoesNothing() + { + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1) + ->willReturn(['transaction' => 123]); + $this->connection->commit()->shouldNotBeCalled(); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE + ); + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->gc(100); + + $this->assertTrue($ret); + } + + public function testGc() + { + $phpunit = $this; + $this->documents->valid() + ->shouldBeCalledTimes(2) + ->willReturn(true, false); + $this->documents->current() + ->shouldBeCalledTimes(1) + ->willReturn([ + 'document' => [ + 'name' => $this->documentName(), + 'fields' => [], + 'createTime' => date('Y-m-d'), + 'updateTime' => date('Y-m-d'), + ], + 'readTime' => date('Y-m-d'), + ]); + $this->documents->next() + ->shouldBeCalledTimes(1); + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1) + ->willReturn(['transaction' => 123]); + $this->connection->runQuery(Argument::any()) + ->shouldBeCalledTimes(1) + ->will(function ($args) use ($phpunit) { + $options = $args[0]; + $phpunit->assertEquals( + $phpunit->dbName() . '/documents', + $options['parent'] + ); + $phpunit->assertEquals(999, $options['structuredQuery']['limit']); + $phpunit->assertEquals( + self::SESSION_SAVE_PATH . ':' . self::SESSION_NAME, + $options['structuredQuery']['from'][0]['collectionId'] + ); + $phpunit->assertEquals(123, $options['transaction']); + return $phpunit->documents->reveal(); + }); + $this->valueMapper->decodeValues([]) + ->shouldBeCalledTimes(1) + ->willReturn(['data' => 'sessiondata']); + $this->valueMapper->encodeValue(Argument::type('integer')) + ->shouldBeCalledTimes(1); + $this->connection->commit([ + 'database' => $this->dbName(), + 'writes' => [ + [ + 'delete' => $this->documentName() + ] + ], + 'transaction' => 123 + ]) + ->shouldBeCalledTimes(1); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE, + ['gcLimit' => 999, 'query' => ['maxRetries' => 0]] + ); + + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->gc(100); + + $this->assertTrue($ret); + } + + /** + * @expectedException PHPUnit_Framework_Error_Warning + */ + public function testGcWithException() + { + $this->connection->beginTransaction(['database' => $this->dbName()]) + ->shouldBeCalledTimes(1) + ->willReturn(['transaction' => 123]); + $this->connection->runQuery(Argument::any()) + ->shouldBeCalledTimes(1) + ->willThrow(new ServiceException('')); + $firestoreSessionHandler = new FirestoreSessionHandler( + $this->connection->reveal(), + $this->valueMapper->reveal(), + self::PROJECT, + self::DATABASE, + ['gcLimit' => 1000, 'query' => ['maxRetries' => 0]] + ); + + $firestoreSessionHandler->open(self::SESSION_SAVE_PATH, self::SESSION_NAME); + $ret = $firestoreSessionHandler->gc(100); + + $this->assertFalse($ret); + } + + private function dbName() + { + return sprintf( + 'projects/%s/databases/%s', + self::PROJECT, + self::DATABASE + ); + } + + private function documentName() + { + return sprintf( + '%s/documents/%s:%s/sessionid', + $this->dbName(), + self::SESSION_SAVE_PATH, + self::SESSION_NAME + ); + } +}