From 70204014554780905d533e65b61abcebc7fb2c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kucha=C5=99?= <honza.kuchar@grifart.cz> Date: Sun, 8 May 2016 21:14:32 +0200 Subject: [PATCH] added SemanticCursor: provides more intuitive interface --- src/PostgresDriver/CursorException.php | 4 +- src/PostgresDriver/SemanticCursor.php | 240 +++++++++++++++++ .../Store/PostgresDriver/SemanticCursor.phpt | 241 ++++++++++++++++++ .../SemanticCursorIntegration.phpt | 56 ++++ 4 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 src/PostgresDriver/SemanticCursor.php create mode 100644 tests/Store/PostgresDriver/SemanticCursor.phpt create mode 100644 tests/Store/PostgresDriver/SemanticCursorIntegration.phpt diff --git a/src/PostgresDriver/CursorException.php b/src/PostgresDriver/CursorException.php index 70e641e..dfd0ec1 100644 --- a/src/PostgresDriver/CursorException.php +++ b/src/PostgresDriver/CursorException.php @@ -60,7 +60,7 @@ class CursorException extends \LogicException public static function cursorPosition_invalidIndexValue(int $value) : self { return new static( - sprintf("Index value %i is not valid.", $value) + sprintf("Index value %d is not valid.", $value) ); } @@ -74,7 +74,7 @@ class CursorException extends \LogicException public static function unexpectedRowCountReturned(int $rowCount) : self { return new static( - sprintf("Underlying driver failed. Returned unexpected number (%i) of rows.", $rowCount) + sprintf("Underlying driver failed. Returned unexpected number (%d) of rows.", $rowCount) ); } diff --git a/src/PostgresDriver/SemanticCursor.php b/src/PostgresDriver/SemanticCursor.php new file mode 100644 index 0000000..457f9c6 --- /dev/null +++ b/src/PostgresDriver/SemanticCursor.php @@ -0,0 +1,240 @@ +<?php declare(strict_types = 1); +/** + * This file is part of mappi/store. + */ + +namespace Grifart\Mappi\Store\PostgresDriver; + +/** + * Adds more semantics into cursor API. + * + * Use this terminology: + * - BEGINNING: the position before first row (initial) + * - FIRST: the first row position + * - LAST: the last row position + * - END: the position after last row (end position) + * + * @package Grifart\Mappi\Store\PostgresDriver + */ +class SemanticCursor implements ICursorDriver +{ + /** @var ICursorDriver */ + private $cursor; + + /** + * @param ICursorDriver $cursor + */ + public function __construct(ICursorDriver $cursor) + { + $this->cursor = $cursor; + } + + // Classic driver part: + + public function getName() : string + { + return $this->cursor->getName(); + } + + public function isOnRecord() : bool + { + return $this->cursor->isOnRecord(); + } + + public function moveTo(int $index) + { + $this->cursor->moveTo($index); + } + + public function moveFromEndTo(int $index) + { + return $this->cursor->moveFromEndTo($index); + } + + public function moveBy(int $rows) + { + $this->cursor->moveBy($rows); + } + + public function fetchRange(int $rows) : array + { + return $this->cursor->fetchRange($rows); + } + + public function fetchOneAt(int $index) + { + return $this->cursor->fetchOneAt($index); + } + + public function fetchOneBy(int $rows) + { + return $this->cursor->fetchOneBy($rows); + } + + // The extension: + + /** + * Move cursor on the last row + * @see fetchCurrent() + * @see fetchCurrentSingle() + * @return void + */ + public function moveToLast() + { + $this->moveFromEndTo(1); + } + + /** + * Move cursor on the first row (index=1) + * @see fetchCurrent() + * @see fetchCurrentSingle() + * @return void + */ + public function moveToFirst() + { + $this->moveTo(1); + } + + /** + * Moves cursor to position BEFORE first row (index=0) + * @see fetchNext() + * @see fetchNextSingle() + * @return void + */ + public function moveToBeginning() + { + $this->moveTo(0); + } + + /** + * Moves cursor to position AFTER last row + * @return void + */ + public function moveToEnd() + { + $this->moveFromEndTo(0); + } + + // ---- FETCH ---- + + /** + * Move cursor to the next position and return row + * @return array|NULL the next row + * @throws CursorException + */ + public function fetchNext() + { + return $this->extractOneRowResult( + $this->fetchRange(1) + ); + } + + /** + * Return row currently standing on + * @return array|NULL the current row + * @throws CursorException + */ + public function fetchCurrent() + { + return $this->extractOneRowResult( + $this->fetchRange(0) + ); + } + + /** + * Move cursor one back and fetch current row + * @return array|NULL the previous row + * @throws CursorException + */ + public function fetchPrevious() + { + return $this->extractOneRowResult( + $this->fetchRange(-1) + ); + } + + /** + * Move cursor to next position and fetch first column value. + * @return mixed the first column on next row value + * @throws CursorException + */ + public function fetchNextSingle() + { + return $this->extractFirstColumnFromRow( + $this->fetchNext() + ); + } + + /** + * Fetch first column value. + * @return mixed the first column on current row value + * @throws CursorException + */ + public function fetchCurrentSingle() + { + return $this->extractFirstColumnFromRow( + $this->fetchCurrent() + ); + } + + /** + * Move cursor to previous position and fetch first column value. + * @return mixed the first column on previous row value + * @throws CursorException + */ + public function fetchPreviousSingle() + { + return $this->extractFirstColumnFromRow( + $this->fetchPrevious() + ); + } + + /** + * Fetch remaining rows. Cursor will be in END position after this operation. + * @return array[] remaining rows + */ + public function fetchRemaining() : array + { + return $this->cursor->fetchRange( + ICursorDriver::FETCH_REMAINING + ); + } + + /** + * Fetch foregoing rows. Cursor will be in BEGINNING position after this operation. + * First returned row will be *previous* row. + * @return array[] foregoing rows + */ + public function fetchForegoing() : array + { + return $this->cursor->fetchRange( + ICursorDriver::FETCH_FOREGOING + ); + } + + /** + * @param array $rows + * @return array|NULL + * @throws CursorException + */ + private function extractOneRowResult(array $rows) + { + $count = count($rows); + if ($count === 1) { + return reset($rows); // first element + } elseif ($count === 0) { + return NULL; + } + throw CursorException::unexpectedRowCountReturned($count); + } + + /** + * @param array $row + * @return mixed The value + */ + private function extractFirstColumnFromRow(array $row) + { + return reset($row); + } + +} \ No newline at end of file diff --git a/tests/Store/PostgresDriver/SemanticCursor.phpt b/tests/Store/PostgresDriver/SemanticCursor.phpt new file mode 100644 index 0000000..5d75532 --- /dev/null +++ b/tests/Store/PostgresDriver/SemanticCursor.phpt @@ -0,0 +1,241 @@ +<?php +/** + * @testCase + */ + +namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver; + +use Grifart\Mappi\Store\PostgresDriver\CursorDriver; +use Grifart\Mappi\Store\PostgresDriver\CursorDriverFactory; +use Grifart\Mappi\Store\PostgresDriver\CursorException; +use Grifart\Mappi\Store\PostgresDriver\CursorFactory; +use Grifart\Mappi\Store\PostgresDriver\ICursorDriver; +use Grifart\Mappi\Store\PostgresDriver\SemanticCursor; +use Grifart\Mappi\Tests\Store\BaseTest; +use Mockery; +use Tester\Assert; + +require_once __DIR__ . "/../../bootstrap.php"; +require_once __DIR__ . "/CursorInterfaceTest.php"; + +/** + * Behavioral test for SemanticCursor using mocking. + * @link http://docs.mockery.io/en/latest/ + * + * @package Grifart\Mappi\Tests\Store\Store\PostgresDriver + */ +class SemanticCursorTest extends BaseTest +{ + /** @var SemanticCursor */ + private $uut; + + /** @var ICursorDriver|Mockery\Mock */ + private $mockedCursor; + + protected function setUp() + { + parent::setUp(); + + /** @var ICursorDriver|Mockery\Mock $cursor */ + $this->mockedCursor = Mockery::mock(ICursorDriver::class); + $this->uut = new SemanticCursor($this->mockedCursor); + } + + // ---- proxy ---- + public function test_getName() + { + $this->mockedCursor->shouldReceive("getName") + ->once()->andReturn("test"); + + Assert::equal("test", $this->uut->getName()); + } + + public function test_isOnRecord() + { + $this->mockedCursor->shouldReceive("isOnRecord") + ->once()->andReturn(true); + + Assert::equal(true, $this->uut->isOnRecord()); + } + + public function test_moveTo() + { + $this->mockedCursor->shouldReceive("moveTo") + ->with(5)->once(); + + $this->uut->moveTo(5); + } + + public function test_moveFromEndTo() + { + $this->mockedCursor->shouldReceive("moveFromEndTo") + ->with(0)->once(); + + $this->uut->moveFromEndTo(0); + } + + public function test_moveBy() + { + $this->mockedCursor->shouldReceive("moveBy") + ->with(-8)->once(); + + $this->uut->moveBy(-8); + } + + public function test_fetchRange() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(8)->once()->andReturn(["val1"]); + + Assert::equal(["val1"], $this->uut->fetchRange(8)); + } + + public function test_fetchOneAt() + { + $this->mockedCursor->shouldReceive("fetchOneAt") + ->with(5)->once()->andReturn(["hi!"]); + + Assert::equal(["hi!"], $this->uut->fetchOneAt(5)); + } + + public function test_fetchOneBy() + { + $this->mockedCursor->shouldReceive("fetchOneBy") + ->with(5)->once()->andReturn(["hi!"]); + + Assert::equal(["hi!"], $this->uut->fetchOneBy(5)); + } + + // ---- the extension - move* ---- + + public function test_moveToLast() + { + $this->mockedCursor->shouldReceive("moveFromEndTo") + ->with(1)->once(); + + $this->uut->moveToLast(); + } + + public function test_moveToFirst() + { + $this->mockedCursor->shouldReceive("moveTo") + ->with(1)->once(); + + $this->uut->moveToFirst(); + } + + public function test_moveToBeginning() + { + $this->mockedCursor->shouldReceive("moveTo") + ->with(0)->once(); + + $this->uut->moveToBeginning(); + } + + public function test_moveToEnd() + { + $this->mockedCursor->shouldReceive("moveFromEndTo") + ->with(0)->once(); + + $this->uut->moveToEnd(); + } + + // ---- the extension - fetch* ---- + + public function test_fetchNext() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(1)->once()->andReturn([["next"]]); + + Assert::equal(["next"], $this->uut->fetchNext()); + } + + public function test_fetchNext_givenEmptyResult_whenFetchNext_thenGetNull() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(1)->once()->andReturn([]); + + Assert::null($this->uut->fetchNext()); + } + + public function test_fetchNext_givenMoreRowsReturned_whenFetchNext_thenGetError() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(1)->once()->andReturn([["row1"],["row2"]]); + + Assert::exception(function() { + $this->uut->fetchNext(); + }, CursorException::class, "Underlying driver failed. Returned unexpected number (2) of rows."); + } + + public function test_fetchCurrent() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(0)->once()->andReturn([["current"]]); + + Assert::equal(["current"], $this->uut->fetchCurrent()); + } + + public function test_fetchPrevious() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(-1)->once()->andReturn([["previous"]]); + + Assert::equal(["previous"], $this->uut->fetchPrevious()); + } + + // ---- the extension - fetch*Single ---- + + public function test_fetchNextSingle() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(1)->once()->andReturn([["next", "col2"]]); + + Assert::equal("next", $this->uut->fetchNextSingle()); + } + + public function test_fetchCurrentSingle() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(0)->once()->andReturn([["current"]]); + + Assert::equal("current", $this->uut->fetchCurrentSingle()); + } + + public function test_fetchPreviousSingle() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(-1)->once()->andReturn([["previous"]]); + + Assert::equal("previous", $this->uut->fetchPreviousSingle()); + } + + public function test_fetchRemaining() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(ICursorDriver::FETCH_REMAINING)->once() + ->andReturn([["next1"],["next2"]]); + + Assert::equal( + [["next1"],["next2"]], + $this->uut->fetchRemaining() + ); + } + + public function test_fetchForegoing() + { + $this->mockedCursor->shouldReceive("fetchRange") + ->with(ICursorDriver::FETCH_FOREGOING)->once() + ->andReturn([["prev1"],["prev2"]]); + + Assert::equal( + [["prev1"],["prev2"]], + $this->uut->fetchForegoing() + ); + } + + + +} + +(new SemanticCursorTest())->run(); diff --git a/tests/Store/PostgresDriver/SemanticCursorIntegration.phpt b/tests/Store/PostgresDriver/SemanticCursorIntegration.phpt new file mode 100644 index 0000000..2c6d0c5 --- /dev/null +++ b/tests/Store/PostgresDriver/SemanticCursorIntegration.phpt @@ -0,0 +1,56 @@ +<?php +/** + * @testCase + */ + +namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver; + +use Grifart\Mappi\Store\PostgresDriver\CursorDriver; +use Grifart\Mappi\Store\PostgresDriver\CursorDriverFactory; +use Grifart\Mappi\Store\PostgresDriver\CursorException; +use Grifart\Mappi\Store\PostgresDriver\CursorFactory; +use Grifart\Mappi\Store\PostgresDriver\ICursorDriver; +use Grifart\Mappi\Store\PostgresDriver\SemanticCursor; +use Grifart\Mappi\Tests\Store\BaseTest; +use Mockery; +use Tester\Assert; + +require_once __DIR__ . "/../../bootstrap.php"; +require_once __DIR__ . "/CursorInterfaceTest.php"; + +/** + * Behavioral test for SemanticCursor using mocking. + * @link http://docs.mockery.io/en/latest/ + * + * @package Grifart\Mappi\Tests\Store\Store\PostgresDriver + */ +class SemanticCursorIntegrationTest extends CursorInterfaceTest +{ + + /** @var SemanticCursor */ + protected $uut; + + protected function setUp() + { + global $connection, $SQL_thousandRowsAscending; + $connection->begin(); + + $this->uut = new SemanticCursor( + (new CursorDriverFactory($connection)) + ->create($SQL_thousandRowsAscending, true) + ); + + parent::setUp(); + } + + public function tearDown() + { + global $connection; + $connection->rollback(); + + parent::tearDown(); + } + +} + +(new SemanticCursorIntegrationTest())->run(); -- GitLab