From f651573d6bc445f3a0a17feb97d0e986b675ec29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kucha=C5=99?= <honza.kuchar@grifart.cz> Date: Fri, 6 May 2016 23:51:03 +0200 Subject: [PATCH] Introducing TrackingCursor which knows it's current position --- .../{FastCursor.php => Cursor.php} | 28 +- src/PostgresDriver/CursorException.php | 29 +++ src/PostgresDriver/ICursor.php | 8 +- src/PostgresDriver/TrackedCursor.php | 242 ++++++++++++++++++ .../PostgresDriver/CursorInterfaceTest.php | 39 +-- tests/Store/PostgresDriver/CursorTest.phpt | 76 ++++++ .../Store/PostgresDriver/FastCursorTest.phpt | 33 --- .../PostgresDriver/TrackedCursorTest.phpt | 159 ++++++++++++ 8 files changed, 528 insertions(+), 86 deletions(-) rename src/PostgresDriver/{FastCursor.php => Cursor.php} (89%) create mode 100644 src/PostgresDriver/TrackedCursor.php create mode 100644 tests/Store/PostgresDriver/CursorTest.phpt delete mode 100644 tests/Store/PostgresDriver/FastCursorTest.phpt create mode 100644 tests/Store/PostgresDriver/TrackedCursorTest.phpt diff --git a/src/PostgresDriver/FastCursor.php b/src/PostgresDriver/Cursor.php similarity index 89% rename from src/PostgresDriver/FastCursor.php rename to src/PostgresDriver/Cursor.php index 808309f..cc11e09 100644 --- a/src/PostgresDriver/FastCursor.php +++ b/src/PostgresDriver/Cursor.php @@ -7,7 +7,7 @@ namespace Grifart\Mappi\Store\PostgresDriver; use Dibi\Connection; -class FastCursor implements ICursor +class Cursor implements ICursor { /** @var Connection */ @@ -40,7 +40,7 @@ class FastCursor implements ICursor return $this->connection; } - public function getCursorName() : string + public function getName() : string { return $this->cursorName; } @@ -55,7 +55,7 @@ class FastCursor implements ICursor $result = $this->getConnection()->query( "MOVE ABSOLUTE %i IN %n", $index, - $this->getCursorName() + $this->getName() ); if($result !== 1) { throw CursorException::cursorOverflow(); @@ -67,7 +67,7 @@ class FastCursor implements ICursor $result = $this->getConnection()->query( "MOVE RELATIVE %i IN %n", $rows, - $this->getCursorName() + $this->getName() ); if($result !== 1) { throw CursorException::cursorOverflow(); @@ -78,7 +78,7 @@ class FastCursor implements ICursor { $this->getConnection()->query( "MOVE ABSOLUTE 0 IN %n;", - $this->getCursorName() + $this->getName() ); } @@ -86,7 +86,7 @@ class FastCursor implements ICursor { $result = $this->getConnection()->query( "MOVE FIRST IN %n;", // == ABSOLUTE 1 - $this->getCursorName() + $this->getName() ); if($result !== 1) { throw CursorException::cursorOverflow(); @@ -97,7 +97,7 @@ class FastCursor implements ICursor { $result = $this->getConnection()->query( "MOVE LAST IN %n", // == ABSOLUTE -1 - $this->getCursorName() + $this->getName() ); if($result !== 1) { throw CursorException::cursorOverflow(); @@ -108,8 +108,8 @@ class FastCursor implements ICursor { $this->getConnection()->query( "MOVE ABSOLUTE -1 IN %n; MOVE NEXT IN %n;", - $this->getCursorName(), - $this->getCursorName() + $this->getName(), + $this->getName() ); } @@ -122,7 +122,7 @@ class FastCursor implements ICursor return $this->connection->query( $forward ? "FETCH FORWARD %i FROM %n" : "FETCH BACKWARD %i FROM %n", $rows, - $this->getCursorName() + $this->getName() )->fetchAll(); } @@ -169,7 +169,7 @@ class FastCursor implements ICursor return $this->connection->query( "FETCH ABSOLUTE %i FROM %n", $index, - $this->getCursorName() + $this->getName() )->fetch(); } @@ -178,7 +178,7 @@ class FastCursor implements ICursor return $this->connection->query( "FETCH RELATIVE %i FROM %n", $rows, - $this->getCursorName() + $this->getName() )->fetch(); } @@ -186,7 +186,7 @@ class FastCursor implements ICursor { return $this->connection->query( "FETCH FORWARD ALL FROM %n", - $this->getCursorName() + $this->getName() )->fetchAll(); } @@ -194,7 +194,7 @@ class FastCursor implements ICursor { return $this->connection->query( "FETCH BACKWARD ALL FROM %n", - $this->getCursorName() + $this->getName() )->fetchAll(); } diff --git a/src/PostgresDriver/CursorException.php b/src/PostgresDriver/CursorException.php index b5fa8e4..e5d24d5 100644 --- a/src/PostgresDriver/CursorException.php +++ b/src/PostgresDriver/CursorException.php @@ -11,6 +11,13 @@ class CursorException extends \LogicException { const MESSAGE_NO_DATA_TO_FETCH = "There was not data to fetch. Haven't you reached end of cursor?"; const MESSAGE_OVERFLOW = "Cursor overflow. You've hit end or beginning of the cursor."; + const MESSAGE_NO_DATA_CURSOR_RESET = "No data received. Cursor has been reset to zero."; + const MESSAGE_CANNOT_RECOVER_TO_ORIGINAL_POSITION = "Cursor is in undefined state. " . + "Error occurred and system tried to reset cursor into state before operation. " . + "Unfortunately it failed. Original exception attached."; + const MESSAGE_UNTRACEABLE_VALUE = "Given value is not traceable. %s"; + const MESSAGE_CANNOT_REACH_THE_END = "Cannot move cursor to end. Cursor was " . + "in undirected state. Cursor has been left in unknown state."; /** * @inheritDoc @@ -30,4 +37,26 @@ class CursorException extends \LogicException return new static(self::MESSAGE_OVERFLOW); } + public static function noDataCursorHasBeenSetToZero() + { + return new static(self::MESSAGE_NO_DATA_CURSOR_RESET); + } + + public static function cannotRecoverCursorIntoOriginalPosition(\Throwable $previous) + { + return new static(self::MESSAGE_CANNOT_RECOVER_TO_ORIGINAL_POSITION, 0, $previous); + } + + public static function cannotMoveToTheEnd() + { + return new static(self::MESSAGE_CANNOT_REACH_THE_END); + } + + public static function untraceableValue($moreInfo) + { + return new static( + sprintf(self::MESSAGE_UNTRACEABLE_VALUE, $moreInfo) + ); + } + } \ No newline at end of file diff --git a/src/PostgresDriver/ICursor.php b/src/PostgresDriver/ICursor.php index 1fe4f8e..3f3d31e 100644 --- a/src/PostgresDriver/ICursor.php +++ b/src/PostgresDriver/ICursor.php @@ -3,6 +3,7 @@ * This file is part of mappi/store. */ namespace Grifart\Mappi\Store\PostgresDriver; +use Dibi\Connection; /** * Represents PostgreSQL cursor @@ -13,7 +14,12 @@ namespace Grifart\Mappi\Store\PostgresDriver; */ interface ICursor { - public function getCursorName() : string; + public function getName() : string; + + /** + * @internal + */ + public function getConnection() : Connection; /** * @param int $rows diff --git a/src/PostgresDriver/TrackedCursor.php b/src/PostgresDriver/TrackedCursor.php new file mode 100644 index 0000000..e8d479e --- /dev/null +++ b/src/PostgresDriver/TrackedCursor.php @@ -0,0 +1,242 @@ +<?php declare(strict_types = 1); +/** + * This file is part of mappi/store. + */ + +namespace Grifart\Mappi\Store\PostgresDriver; + +use Dibi\Connection; +use Dibi\NotImplementedException; +use MongoDB\Driver\Cursor; + +class TrackedCursor implements ICursor +{ + + /** @var int */ + private $total; + + /** + * null-based index of current cursor state + * @var int + */ + private $position = 0; + + + /** @var ICursor */ + private $cursor; + + /** + * @param ICursor $cursor Cursor in initial state (index=0) + * Tip: if you are not sure that cursor will be in initial state, call ->moveToBeginning() after initialization. + */ + public function __construct(ICursor $cursor) + { + $this->cursor = $cursor; + } + + /** + * @return int + */ + public function getPosition() + { + return $this->position; + } + + public function getConnection() : Connection + { + return $this->cursor->getConnection(); + } + + public function getName() : string + { + return $this->cursor->getName(); + } + + public function moveTo(int $index) + { + if($index < 0) { + throw CursorException::untraceableValue("Given index is bellow zero. Use moveToEnd() + moveBy() combination."); + } + if($this->handleSpecialCases($index)) { // this was special case and was handled by special handling bellow + return; + } + $this->cursor->moveTo($index); + $this->position = $index; + } + + public function moveBy(int $rows) + { + $originalPosition = $this->getPosition(); + $index = $originalPosition + $rows; + //$this->moveTo($index); // todo: is this efficient? + + if($this->handleSpecialCases($originalPosition + $rows)) { // this was special case and was handled by special handling bellow + return; + } + try { + $this->cursor->moveBy($rows); + $this->position += $rows; + } catch (CursorException $e) { + $this->tryToRecoverFromError( + CursorException::cursorOverflow(), + $originalPosition + ); + } + } + + public function moveToBeginning() + { + $this->cursor->moveToBeginning(); + $this->position = 0; + } + + public function moveToFirst() + { + $this->cursor->moveToFirst(); + $this->position = 1; + } + + public function moveToLast() + { + $this->moveToEnd(); + $this->moveBy(-1); + } + + public function moveToEnd() + { + $rowsSkipped = $this->getConnection()->query( + "MOVE FORWARD ALL IN %n", + $this->getName() + ); + if ($rowsSkipped === 0) { + // edge case: when already in END or LAST position + if ($this->total !== NULL) + { + $indexOfEnd = $this->total + 1; + $indexOfLast = $this->total; + if ( + $this->position === $indexOfLast || + $this->position === $indexOfEnd + ) + { + $this->position = $indexOfEnd; + return; + } + + } else { + throw CursorException::cannotMoveToTheEnd(); + } + } + $this->position += $rowsSkipped + 1; + $this->total = $this->position - 1; // end is one step after last item + } + + public function fetch(int $rows) + { + $forward = $rows > 0; + $rows = $this->cursor->fetch($rows); + $this->position += count($rows) * ($forward ? 1 : -1); + return $rows; + } + + public function fetchNext() + { + $row = $this->cursor->fetchNext(); + if($row !== FALSE) { + $this->position += 1; + } + return $row; + } + + public function fetchCurrent() + { + return $this->cursor->fetchCurrent(); + } + + public function fetchNextSingle() + { + $value = $this->cursor->fetchNextSingle(); + $this->position += 1; + return $value; + } + + public function fetchCurrentSingle() + { + return $this->cursor->fetchCurrentSingle(); + } + + public function fetchOneAt(int $index) + { + $originalPosition = $this->getPosition(); + $row = $this->cursor->fetchOneAt($index); + if($row !== FALSE) { + $this->position = $index; + } else { + $this->tryToRecoverFromError( + CursorException::noDataCursorHasBeenSetToZero(), + $originalPosition + ); + } + return $row; + } + + public function fetchOneBy(int $rows) + { + $originalPosition = $this->getPosition(); + $row = $this->cursor->fetchOneBy($rows); + if($row !== FALSE) { + $this->position += $rows; + } else { + $this->tryToRecoverFromError( + CursorException::noDataCursorHasBeenSetToZero(), + $originalPosition + ); + } + return $row; + } + + public function fetchRemaining() + { + $data = $this->cursor->fetchRemaining(); + $this->position += count($data); + return $data; + } + + public function fetchForegoing() + { + $data = $this->cursor->fetchForegoing(); + $this->position -= count($data); + return $data; + } + + private function handleSpecialCases($index) : bool + { + // if requested position was BEGINNING + if($index === 0) { + $this->moveToBeginning(); + return TRUE; + } + + // if requested position was the END + if($this->total !== NULL) { + if($this->total + 1 === $index) { + $this->moveToEnd(); + return TRUE; + } + } + + // wasn't a special case + return FALSE; + } + + private function tryToRecoverFromError(\Throwable $throwable, $moveTo) + { + try { + $this->moveTo($moveTo); + } catch (\Throwable $e) { + throw CursorException::cannotRecoverCursorIntoOriginalPosition($throwable); + } + + throw $throwable; + } +} \ No newline at end of file diff --git a/tests/Store/PostgresDriver/CursorInterfaceTest.php b/tests/Store/PostgresDriver/CursorInterfaceTest.php index 7aa7872..7cdfb2f 100644 --- a/tests/Store/PostgresDriver/CursorInterfaceTest.php +++ b/tests/Store/PostgresDriver/CursorInterfaceTest.php @@ -236,37 +236,6 @@ abstract class CursorInterfaceTest extends BaseTest }, CursorException::class, CursorException::MESSAGE_OVERFLOW); } - public function test_givenFirstPosition_whenMoveToLeft_thenGetError() - { - $this->uut->moveToFirst(); - - Assert::exception(function() { - $this->uut->moveBy(-1); - }, CursorException::class, CursorException::MESSAGE_OVERFLOW); - - // todo: unfortunately moveBy moved cursor into index=0 even when exception occured - // todo: this should be supported or should not modify state - - Assert::exception(function() { - $this->uut->fetchCurrentSingle(); - }, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH); - } - - public function test_givenLastPosition_whenMoveToRight_thenGetError() - { - $this->uut->moveToLast(); - - Assert::exception(function() { - $this->uut->moveBy(1); - }, CursorException::class, CursorException::MESSAGE_OVERFLOW); - - // todo: unfortunately moveBy moved cursor into index=0 even when exception occured - // todo: this should be supported or should not modify state - - Assert::exception(function() { - $this->uut->fetchCurrentSingle(); - }, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH); - } public function test_giveSomePosition_whenMoveToAfterEnd_thenGetError() { @@ -276,12 +245,6 @@ abstract class CursorInterfaceTest extends BaseTest }, CursorException::class, CursorException::MESSAGE_OVERFLOW); } - public function test_giveSomePosition_whenMoveToBeforeBeginning_thenGetError() - { - $this->uut->moveTo(1); - Assert::exception(function() { - $this->uut->moveTo(0); - }, CursorException::class, CursorException::MESSAGE_OVERFLOW); - } + } diff --git a/tests/Store/PostgresDriver/CursorTest.phpt b/tests/Store/PostgresDriver/CursorTest.phpt new file mode 100644 index 0000000..beef539 --- /dev/null +++ b/tests/Store/PostgresDriver/CursorTest.phpt @@ -0,0 +1,76 @@ +<?php +/** + * @testCase + */ + +namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver; + +use Grifart\Mappi\Store\PostgresDriver\CursorException; +use Grifart\Mappi\Store\PostgresDriver\Cursor; +use Tester\Assert; + +require_once __DIR__ . "/../../bootstrap.php"; +require_once __DIR__ . "/CursorInterfaceTest.php"; + +class CursorTest extends CursorInterfaceTest +{ + protected function setUp() + { + global $connection, $SQL_thousandRowsAscending; + $connection->begin(); + + $this->uut = new Cursor($connection, $SQL_thousandRowsAscending, true); + parent::setUp(); + } + + public function tearDown() + { + global $connection; + $connection->rollback(); + + parent::tearDown(); + } + + public function test_givenFirstPosition_whenMoveToLeft_thenGetError() + { + $this->uut->moveToFirst(); + + Assert::exception(function() { + $this->uut->moveBy(-1); + }, CursorException::class, CursorException::MESSAGE_OVERFLOW); + + // todo: unfortunately moveBy moved cursor into index=0 even when exception occured + // todo: this should be supported or should not modify state + + Assert::exception(function() { + $this->uut->fetchCurrentSingle(); + }, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH); + } + + public function test_givenLastPosition_whenMoveToRight_thenGetError() + { + $this->uut->moveToLast(); + + Assert::exception(function() { + $this->uut->moveBy(1); + }, CursorException::class, CursorException::MESSAGE_OVERFLOW); + + // todo: unfortunately moveBy moved cursor into index=0 even when exception occured + // todo: this should be supported or should not modify state + + Assert::exception(function() { + $this->uut->fetchCurrentSingle(); + }, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH); + } + + public function test_giveSomePosition_whenMoveToBeginning_thenGetError() + { + $this->uut->moveTo(1); + // todo: fixme? + Assert::exception(function() { + $this->uut->moveTo(0); + }, CursorException::class); + } +} + +(new CursorTest())->run(); diff --git a/tests/Store/PostgresDriver/FastCursorTest.phpt b/tests/Store/PostgresDriver/FastCursorTest.phpt deleted file mode 100644 index ea8a9b6..0000000 --- a/tests/Store/PostgresDriver/FastCursorTest.phpt +++ /dev/null @@ -1,33 +0,0 @@ -<?php -/** - * @testCase - */ - -namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver; - -use Grifart\Mappi\Store\PostgresDriver\FastCursor; - -require_once __DIR__ . "/../../bootstrap.php"; -require_once __DIR__ . "/CursorInterfaceTest.php"; - -class FastCursorTest extends CursorInterfaceTest -{ - protected function setUp() - { - global $connection, $SQL_thousandRowsAscending; - $connection->begin(); - - $this->uut = new FastCursor($connection, $SQL_thousandRowsAscending, true); - parent::setUp(); - } - - public function tearDown() - { - global $connection; - $connection->rollback(); - - parent::tearDown(); - } -} - -(new FastCursorTest())->run(); diff --git a/tests/Store/PostgresDriver/TrackedCursorTest.phpt b/tests/Store/PostgresDriver/TrackedCursorTest.phpt new file mode 100644 index 0000000..7da5bf8 --- /dev/null +++ b/tests/Store/PostgresDriver/TrackedCursorTest.phpt @@ -0,0 +1,159 @@ +<?php +/** + * @testCase + */ + +namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver; + +use Grifart\Mappi\Store\PostgresDriver\CursorException; +use Grifart\Mappi\Store\PostgresDriver\Cursor; +use Grifart\Mappi\Store\PostgresDriver\TrackedCursor; +use Tester\Assert; + +require_once __DIR__ . "/../../bootstrap.php"; +require_once __DIR__ . "/CursorInterfaceTest.php"; + +class TrackedCursorTest extends CursorInterfaceTest +{ + /** @link https://en.wikipedia.org/wiki/42_(number)#Hitchhiker.27s_Guide_to_the_Galaxy */ + const THE_MAGIC_NUMBER = 42; + + /** @var TrackedCursor */ + protected $uut; + + protected function setUp() + { + global $connection, $SQL_thousandRowsAscending; + $connection->begin(); + + $fastCursor = new Cursor($connection, $SQL_thousandRowsAscending, true); + $this->uut = new TrackedCursor($fastCursor); + parent::setUp(); + } + + public function tearDown() + { + global $connection; + $connection->rollback(); + + parent::tearDown(); + } + + public function test_givenBeginningPosition_whenMoveLeft_thenGetError() + { + $this->uut->moveToBeginning(); + + Assert::exception(function() { + // todo: what to do with this non-uniformity with original Cursor? + $this->uut->moveBy(-1); + }, CursorException::class, CursorException::MESSAGE_OVERFLOW); + + $this->uut->moveBy(1); + Assert::equal(1, $this->uut->fetchCurrentSingle()); + } + + public function test_givenEndPosition_whenMoveToRight_thenGetError() + { + $this->uut->moveToEnd(); + + Assert::exception(function() { + // todo: what to do with this non-uniformity with original Cursor? + $this->uut->moveBy(1); + }, CursorException::class, CursorException::MESSAGE_OVERFLOW); + + $this->uut->moveBy(-1); + Assert::equal(1000, $this->uut->fetchCurrentSingle()); + } + + // corrected behaviour of original cursor + public function test_givenFirstPosition_whenMoveToLeft_thenImOnFirstValue() + { + $this->uut->moveToFirst(); + + $this->uut->moveBy(-1); + // todo: what to do with this non-uniformity with original Cursor? + Assert::equal(1, $this->uut->fetchNextSingle()); + } + + public function test_givenLastPosition_whenMoveToRight_thenGetError() + { + // todo: what to do with this non-uniformity with original Cursor? + $this->uut->moveToLast(); + $this->uut->moveBy(1); // here is ok, now in original cursor + + $this->uut->moveBy(-1); // todo: make decorator which will make this nicer + Assert::equal(1000, $this->uut->fetchCurrentSingle()); + } + + + + // ->getPosition() tests: + + // moveToBeginning() + public function test_givenInitialPosition_whenGetPosition_thenGetZero() + { + Assert::equal(0, $this->uut->getPosition()); + + $this->uut->moveToBeginning(); + Assert::equal(0, $this->uut->getPosition()); + + $this->uut->moveToFirst(); + Assert::equal(1, $this->uut->getPosition()); + } + + public function test_givenMiddlePosition_whenMoveAround_thenGetCorrectPosition() + { + $this->uut->moveTo(self::THE_MAGIC_NUMBER); + $this->uut->moveTo(self::THE_MAGIC_NUMBER + self::THE_MAGIC_NUMBER); + $this->uut->moveBy(-self::THE_MAGIC_NUMBER); + $this->uut->moveBy(+self::THE_MAGIC_NUMBER); + $this->uut->moveBy(-self::THE_MAGIC_NUMBER); + Assert::equal(self::THE_MAGIC_NUMBER, $this->uut->getPosition()); + } + + public function test_givenEndPosition_whenGetPosition_thenGetTotalPlusOne() + { + $this->uut->moveToEnd(); + Assert::equal(1001, $this->uut->getPosition()); + } + + public function test_givenInitialPosition_whenMoveToLast_thenGetTotal() + { + $this->uut->moveToLast(); + Assert::equal(1000, $this->uut->getPosition()); + } + + // nasty edge cases: + public function test_givenLastPosition_whenMoveToEnd_thenGetEndPosition() + { + $this->uut->moveToLast(); + $this->uut->moveToEnd(); + Assert::equal(1001, $this->uut->getPosition()); + + // regression test: + $this->uut->moveToEnd(); + Assert::equal(1001, $this->uut->getPosition()); + } + + public function test_givenEndPosition_whenMoveToLast_thenGetLastPosition() + { + $this->uut->moveToEnd(); + $this->uut->moveToLast(); + Assert::equal(1000, $this->uut->getPosition()); + + // regression test: + $this->uut->moveToLast(); + Assert::equal(1000, $this->uut->getPosition()); + } + + public function test_giveSomePosition_whenMoveToBeginning_thenGetToBeginning() + { + $this->uut->moveTo(42); + $this->uut->moveTo(0); + + Assert::equal(0, $this->uut->getPosition()); + Assert::equal(1, $this->uut->fetchNextSingle()); + } +} + +(new TrackedCursorTest())->run(); \ No newline at end of file -- GitLab