diff --git a/.idea/cursor.iml b/.idea/cursor.iml index fb11120cb8f09a4c48b399157fe673b7f581cbd6..4662101fa8da6efdd5dba0eeb5dd055d180781d0 100644 --- a/.idea/cursor.iml +++ b/.idea/cursor.iml @@ -3,8 +3,8 @@ <component name="NewModuleRootManager"> <content url="file://$MODULE_DIR$"> <sourceFolder url="file://$MODULE_DIR$/vendor/nette/tester/src" isTestSource="false" packagePrefix="Tester" /> - <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="Grifart\Mappi\Store" generated="true" /> - <sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="Grifart\Mappi\Tests\Store" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="Grifart\Mappi\Cursor" generated="true" /> + <sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="Grifart\Mappi\Tests" generated="true" /> <sourceFolder url="file://$MODULE_DIR$/vendor/mockery/mockery/tests" isTestSource="true" packagePrefix="test" /> <sourceFolder url="file://$MODULE_DIR$/vendor/dibi/dibi/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/vendor/hamcrest/hamcrest-php/tests" isTestSource="true" /> @@ -12,6 +12,9 @@ <sourceFolder url="file://$MODULE_DIR$/vendor/mockery/mockery/library" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/vendor/mockery/mockery/tests" isTestSource="true" /> <sourceFolder url="file://$MODULE_DIR$/vendor/mockery/mockery/tests/Mockery/Test" isTestSource="true" packagePrefix="Mockery" /> + <sourceFolder url="file://$MODULE_DIR$/vendor/marc-mabe/php-enum/tests" isTestSource="true" /> + <sourceFolder url="file://$MODULE_DIR$/vendor/tracy/tracy/src" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/vendor/tracy/tracy/src" isTestSource="false" packagePrefix="Tracy" /> </content> <orderEntry type="inheritedJdk" /> <orderEntry type="sourceFolder" forTests="false" /> diff --git a/.idea/modules.xml b/.idea/modules.xml index 5b8593b5db5e5594f7121bb88628ce64c5136c9a..fe19a3834c84e6b870cab7101024ad1969b1c1f6 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,7 @@ <project version="4"> <component name="ProjectModuleManager"> <modules> - <module fileurl="file://$PROJECT_DIR$/../cursor/.idea/cursor.iml" filepath="$PROJECT_DIR$/../cursor/.idea/cursor.iml" /> + <module fileurl="file://$PROJECT_DIR$/.idea/cursor.iml" filepath="$PROJECT_DIR$/.idea/cursor.iml" /> </modules> </component> </project> \ No newline at end of file diff --git a/README.md b/README.md index b2f167550e2aad9e8590decfc930d071de21bf6a..53af6bc8352587b493d0f7d813db9edcea9984e8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,31 @@ -# mappi/store +# mappi/cursor -Provides API for reading data from database using cursors. -This allows you to read huge data without fetching everything into memory. \ No newline at end of file +Provides API for reading data from database using cursors. This allows you to read huge data without all of them into memory. + +Example usage: +````php +use Grifart\Mappi\Cursor; +$connection = new \Dibi\Connection([/* ... */]); +$cursorFactory = new Cursor\Driver\PostgresCursorFactory($connection); + +// cursor allow to do basic operations +$cursor = $cursorFactory->create("SELECT * FROM mytable", TRUE); + +// tracked cursor can tell current position +$trackedCursor = new Cursor\TrackedCursor($cursor, Cursor\Position::fromLeft(0)); + +// semantic cursor provides nicer API for cursor +$semanticCursor = new Cursor\SemanticCursor($cursor); + +while($row = $semanticCursor->fetchNext()) { + echo $trackedCursor->getPosition(); + print_r($row); + echo "<hr>"; +} + +while($row = $semanticCursor->fetchPrevious()) { + echo $trackedCursor->getPosition(); + print_r($row); + echo "<hr>"; +} +```` \ No newline at end of file diff --git a/examples/basic-example.php b/examples/basic-example.php new file mode 100644 index 0000000000000000000000000000000000000000..34257cabbb0af3cb4954ac9442532f7137e4940b --- /dev/null +++ b/examples/basic-example.php @@ -0,0 +1,45 @@ +<?php declare(strict_types = 1); + +require_once __DIR__ . "/../vendor/autoload.php"; + +use Grifart\Mappi\Cursor; + +// using PostgreSQL 9.5 cursor simulation using in-memory data +$driver = new \Grifart\Mappi\Tests\Cursor\Driver\ArrayCursorDriver(); +$driver->createTestCursor("test-cursor", 26684); + +// cursor allow to do basic operations +$cursor = new Cursor\Cursor($driver, "test-cursor"); + +// tracked cursor can tell current position +$trackedCursor = new Cursor\TrackedCursor($cursor, Cursor\Position::fromLeft(0)); + +// semantic cursor provides nicer API for cursor +$semanticCursor = new Cursor\SemanticTrackedCursor($trackedCursor); + +echo "<pre>\n"; + +echo "first row: " . print_r($semanticCursor->fetchNext(), true) . "\n"; + + +$start = 547; +$limit = 20; + +echo "position before move: " . $trackedCursor->getPosition() . "\n"; +$semanticCursor->moveTo(547); +echo "position after move: " . $trackedCursor->getPosition() . "\n"; + +echo "\nposition\tvalue\n"; +$i = $start; +foreach($trackedCursor->fetchRange($limit) as $row) { + $i++; + /** @var array $row */ + echo $i . "\t\t" . json_encode($row) . "\n"; +} +echo "\n"; +echo "position after fetchRange(): " . $trackedCursor->getPosition() . "\n"; + +echo "There is " . $semanticCursor->getTotal() . " rows in the data set\n"; +echo "position after getTotal(): " . $trackedCursor->getPosition() . "\n"; + +echo "</pre>"; diff --git a/src/ArrayCursorDriver.php b/src/ArrayCursorDriver.php deleted file mode 100644 index f982f03fe8d35aed221dc2c92424f6cddbbce378..0000000000000000000000000000000000000000 --- a/src/ArrayCursorDriver.php +++ /dev/null @@ -1,171 +0,0 @@ -<?php declare(strict_types = 1); -/** - * This file is part of mappi/cursor. - */ - -namespace Grifart\Mappi\Cursor; - -/** - * Array Cursor simulates scrolling cursor as implemented in PostgreSQL 9.5. - * - * For more information see ICursor. - * @see ICursor - * @package Grifart\Mappi\Cursor - */ -class ArrayCursorDriver implements ICursorDriver -{ - private $cursors = []; - private $indexes = []; - - public function createTestCursor(string $name, int $length) - { - if(isset($this->cursors[$name])) { - throw new CursorException("Cursor with name $name already exists"); - } - - $this->cursors[$name] = range(0, $length+1); - $this->cursors[$name][0] = null; - $this->cursors[$name][$length+1] = null; - - $this->indexes[$name] = 0; - } - - public function close(string $name) : bool - { - unset($this->cursors[$name]); - return TRUE; - } - - private function getMaxIndex(string $name) { - return count($this->cursors[$name])-1; - } - - private function normalizeIndex(string $name, int &$index) - { - if($index < 0) { - $index = 0; - } - $max = $this->getMaxIndex($name); - if($index > $max) { - $index = $max; - } - } - - private function pointerMoveTo(string $name, int $index) - { - $this->normalizeIndex($name, $index); - $this->indexes[$name] = $index; - } - - private function getCurrentIndex(string $name) : int - { - return $this->indexes[$name]; - } - - private function getCurrentValue(string $name) { - return $this->cursors[$name][$this->getCurrentIndex($name)]; - } - - public function moveTo(string $name, int $index) : bool - { - if ($index < 0) { - throw new \InvalidArgumentException("Negative index not supported. Use moveFromEndTo() instead."); - } - $this->pointerMoveTo($name, $index); - return !!$this->getCurrentValue($name); - } - - private function cursorArrayMoveFromEndTo(string $name, int $index) - { - $max = $this->getMaxIndex($name); - - $indexFromLeft = $max - $index; - $this->normalizeIndex($name, $indexFromLeft); - - $this->moveTo($name, $indexFromLeft); - } - - public function moveFromEndTo(string $name, int $index) : bool - { - if ($index < 0) { - throw new \InvalidArgumentException("Negative index not supported. Use moveTo() instead."); - } - $this->cursorArrayMoveFromEndTo($name, $index); - return !!$this->getCurrentValue($name); - } - - public function moveBy(string $name, int $offset) : bool - { - $i = $this->getCurrentIndex($name); - $indexFromLeft = $i + $offset; - $this->normalizeIndex($name, $indexFromLeft); - $this->moveTo($name, $indexFromLeft); - return !!$this->getCurrentValue($name); - } - - // ----------------- FETCH --------------------- - - public function fetchRange(string $name, int $offset) : array - { - $currentKey = $this->getCurrentIndex($name); - if($offset === self::FETCH_FOREGOING) { - $finalKey = PHP_INT_MIN; - } elseif ($offset === self::FETCH_REMAINING) { - $finalKey = PHP_INT_MAX; - } else { - $finalKey = $currentKey + $offset; - } - $this->normalizeIndex($name, $finalKey); - - $data = []; - if($offset === 0) { - $v = $this->getCurrentValue($name); - if($v !== NULL) { - $data[] = ["n" => $this->getCurrentValue($name)]; - } - return $data; - } - - if($finalKey < $currentKey) { // backwards - for($i = $currentKey-1; $i >= $finalKey; $i--) { - $this->moveTo($name, $i); - $v = $this->getCurrentValue($name); - if($v === NULL) {break;} - $data[] = ["n" => $this->getCurrentValue($name)]; - } - } else { - for($i = $currentKey+1; $i <= $finalKey; $i++) { - $this->moveTo($name, $i); - $v = $this->getCurrentValue($name); - if($v === NULL) {break;} - $data[] = ["n" => $this->getCurrentValue($name)]; - } - } - return $data; - } - - public function fetchOneAt(string $name, int $index) - { - if($index < 0) { - $this->moveFromEndTo($name, abs($index)); - } else { - $this->moveTo($name, $index); - } - $data = $this->fetchRange($name, 0); - if(count($data) === 0) { - return NULL; - } - return $data[0]; - } - - public function fetchOneBy(string $name, int $offset) - { - $this->moveBy($name, $offset); - $data = $this->fetchRange($name, 0); - if(count($data) === 0) { - return NULL; - } - return $data[0]; - } - -} \ No newline at end of file diff --git a/src/Cursor.php b/src/Cursor.php index 70b1bbc8372b956fc893b5040fdec747d53251ba..ca66d34b14c50021fedc049053b0f6e01508cc93 100644 --- a/src/Cursor.php +++ b/src/Cursor.php @@ -4,6 +4,7 @@ */ namespace Grifart\Mappi\Cursor; +use Grifart\Mappi\Cursor\Driver\ICursorDriver; /** * PostgreSQL cursor driver @@ -56,7 +57,7 @@ final class Cursor implements ICursor if ($index < 0) { throw new \InvalidArgumentException("Negative index not supported. Use moveFromEndTo() instead."); } - $this->headOnRecord = $this->driver->moveTo($this->name, $index); + $this->headOnRecord = $this->driver->moveFromBeginningTo($this->name, $index); } public function moveFromEndTo(int $index) @@ -73,6 +74,13 @@ final class Cursor implements ICursor $this->headOnRecord = $this->driver->moveBy($this->name, $offset); } + public function scroll(int $offset) : int + { + $numberOfRows = $this->driver->scroll($this->name, $offset); + $this->headOnRecord = $numberOfRows === abs($offset); + return $numberOfRows; + } + // ----------------- FETCH --------------------- public function fetchRange(int $offset) : array diff --git a/src/CursorPosition.php b/src/CursorPosition.php deleted file mode 100644 index 19383d6f5f3328c01fbc1db361e43fee502e0e22..0000000000000000000000000000000000000000 --- a/src/CursorPosition.php +++ /dev/null @@ -1,102 +0,0 @@ -<?php declare(strict_types = 1); -/** - * This file is part of mappi/cursor. - */ - -namespace Grifart\Mappi\Cursor; - -// todo: make this immutable as VOs should be - -/** - * Value object for TrackedCursor position - * - * @link https://github.com/nicolopignatelli/valueobjects (inspiration) - * @package Grifart\Mappi\Cursor - */ -final class CursorPosition -{ - /** @var CursorPositionOrigin */ - private $origin; - - /** @var int the cursor position */ - private $position = 0; - - /** - * @param CursorPositionOrigin $origin from left or from right? - * @param int $position which position - */ - public function __construct(CursorPositionOrigin $origin, int $position) - { - $this->setPosition($origin, $position); - } - - public static function fromLeft(int $position) : self - { - return new self( - CursorPositionOrigin::get(CursorPositionOrigin::FROM_LEFT), - $position - ); - } - - public static function fromRight(int $position) : self - { - return new self( - CursorPositionOrigin::get(CursorPositionOrigin::FROM_RIGHT), - $position - ); - } - - public function setPositionFromLeft(int $position) - { - $this->setPosition(CursorPositionOrigin::get(CursorPositionOrigin::FROM_LEFT), $position); - } - - public function setPositionFromRight(int $position) - { - $this->setPosition(CursorPositionOrigin::get(CursorPositionOrigin::FROM_RIGHT), $position); - } - - private function setPosition(CursorPositionOrigin $origin, int $position) - { - if($position < 0) { - throw CursorException::cursorPosition_invalidIndexValue($position); - } - $this->origin = $origin; - $this->position = $position; - } - - /** - * @param int $by negative = left; positive = right - */ - public function movePositionBy(int $by) - { - $modifier = 1; - if ($this->origin->is(CursorPositionOrigin::FROM_RIGHT)) { - $modifier = -1; - } - $this->position += $modifier * $by; - } - - /** - * From which side is position counted? - * @return CursorPositionOrigin - */ - public function getOrigin() - { - return $this->origin; - } - - /** - * Cursor position from origin - * @return int - */ - public function getPosition() - { - return $this->position; - } - - function __toString() : string - { - return ($this->origin->getValue() === CursorPositionOrigin::FROM_LEFT ? "left:" : "right:") . $this->position; - } -} \ No newline at end of file diff --git a/src/ICursorDriver.php b/src/Driver/ICursorDriver.php similarity index 86% rename from src/ICursorDriver.php rename to src/Driver/ICursorDriver.php index e4b0d97b44e7c2b31a33276595579ba9b7e67113..aa076dccfb1b1d5537e67aa681f4a6338f32dab8 100644 --- a/src/ICursorDriver.php +++ b/src/Driver/ICursorDriver.php @@ -2,7 +2,7 @@ /** * This file is part of mappi/cursor. */ -namespace Grifart\Mappi\Cursor; +namespace Grifart\Mappi\Cursor\Driver; /** * Represents cursor @@ -33,7 +33,7 @@ interface ICursorDriver * @param int $index * @return bool Head on record? */ - public function moveTo(string $name, int $index) : bool; + public function moveFromBeginningTo(string $name, int $index) : bool; /** * Move cursor from the END to given position @@ -51,6 +51,14 @@ interface ICursorDriver */ public function moveBy(string $name, int $offset) : bool; + /** + * Scroll from current position by given offset + * @param string $name The cursor + * @param int $offset + * @return int Number of records scrolled + */ + public function scroll(string $name, int $offset) : int; + /** * Fetch all rows from current position to given offset * @param string $name The cursor name diff --git a/src/PostgresCursorDriver.php b/src/Driver/PostgresCursorDriver.php similarity index 80% rename from src/PostgresCursorDriver.php rename to src/Driver/PostgresCursorDriver.php index 6d58bdfcfdb73a63c40b2860d3b9e282d886ffce..8dca1338ceb14a1e1f40dad37993ef9a5f3c39ad 100644 --- a/src/PostgresCursorDriver.php +++ b/src/Driver/PostgresCursorDriver.php @@ -3,7 +3,7 @@ * This file is part of mappi/cursor. */ -namespace Grifart\Mappi\Cursor; +namespace Grifart\Mappi\Cursor\Driver; use Dibi\Connection; use Dibi\Row; @@ -31,7 +31,7 @@ class PostgresCursorDriver implements ICursorDriver return $this->connection; } - public function moveTo(string $name, int $index) : bool + public function moveFromBeginningTo(string $name, int $index) : bool { if ($index < 0) { throw new \InvalidArgumentException("Negative index not supported. Use moveFromEndTo() instead."); @@ -73,6 +73,33 @@ class PostgresCursorDriver implements ICursorDriver ); } + public function scroll(string $name, int $offset) : int + { + if($offset === 0) return 0; + $forward = $offset > 0; + $offsetSql = abs($offset); + + if($forward > 0) { + if($offset === self::FETCH_REMAINING) { + $offsetSql = "ALL"; + } + return $this->getConnection()->query( + "MOVE FORWARD %sql IN %n", + $offsetSql, + $name + ); + } else { + if($offset === self::FETCH_FOREGOING) { + $offsetSql = "ALL"; + } + return $this->getConnection()->query( + "MOVE BACKWARD %sql IN %n", + $offsetSql, + $name + ); + } + } + // ----------------- FETCH --------------------- public function fetchRange(string $name, int $offset) : array diff --git a/src/PostgresCursorFactory.php b/src/Driver/PostgresCursorFactory.php similarity index 91% rename from src/PostgresCursorFactory.php rename to src/Driver/PostgresCursorFactory.php index 67b08d88127262dff22f74fabe39e99059543f9b..e2f2468fae982e830536beb10874ade7f097f1b0 100644 --- a/src/PostgresCursorFactory.php +++ b/src/Driver/PostgresCursorFactory.php @@ -3,9 +3,11 @@ * This file is part of mappi/cursor. */ -namespace Grifart\Mappi\Cursor; +namespace Grifart\Mappi\Cursor\Driver; use Dibi\Connection; +use Grifart\Mappi\Cursor\Cursor; +use Grifart\Mappi\Cursor\ICursor; final class PostgresCursorFactory { diff --git a/src/ICursor.php b/src/ICursor.php index 67ae8f55b8d35686df7404ab3e97e06c238454a6..730382c1fa681a6d7340f98df6f7d33fb1a03bb2 100644 --- a/src/ICursor.php +++ b/src/ICursor.php @@ -57,6 +57,13 @@ interface ICursor */ public function moveFromEndTo(int $index); + /** + * Move cursor by given offset and return number of scrolled rows + * @param int $offset + * @return int The number of scrolled rows + */ + public function scroll(int $offset) : int; + /** * Fetch the next/previous count of rows. If zero given current row is fetched. * Cursor position will be on the last fetched row. diff --git a/src/Position.php b/src/Position.php new file mode 100644 index 0000000000000000000000000000000000000000..8a8e9ef19a087e8bd452952ec515ded5bd71d78f --- /dev/null +++ b/src/Position.php @@ -0,0 +1,84 @@ +<?php declare(strict_types = 1); +/** + * This file is part of mappi/cursor. + */ + +namespace Grifart\Mappi\Cursor; + +/** + * Value Object that holds position of cursor + * use by {@see TrackedCursor} + * + * @link https://github.com/nicolopignatelli/valueobjects (inspiration) + * @package Grifart\Mappi\Cursor + */ +final class Position +{ + /** @var PositionOrigin */ + private $origin; + + /** @var int the cursor position */ + private $position = 0; + + /** + * @param PositionOrigin $origin from left or from right? + * @param int $position which position + */ + private function __construct(PositionOrigin $origin, int $position) + { + if($position < 0) { + throw CursorException::cursorPosition_invalidIndexValue($position); + } + $this->origin = $origin; + $this->position = $position; + } + + public static function fromLeft(int $position) : self + { + return new self( + PositionOrigin::get(PositionOrigin::FROM_LEFT), + $position + ); + } + + public static function fromRight(int $position) : self + { + return new self( + PositionOrigin::get(PositionOrigin::FROM_RIGHT), + $position + ); + } + + public static function relativeTo(Position $position, int $offset) : self + { + $positionDelta = ($position->getOrigin() + ->is(PositionOrigin::FROM_LEFT) ? 1 : -1) * $offset; + return new self( + $position->getOrigin(), + $position->getPosition() + $positionDelta + ); + } + + /** + * From which side is position counted? + * @return PositionOrigin + */ + public function getOrigin() + { + return $this->origin; + } + + /** + * Cursor position from origin + * @return int + */ + public function getPosition() + { + return $this->position; + } + + function __toString() : string + { + return ($this->origin->getValue() === PositionOrigin::FROM_LEFT ? "left:" : "right:") . $this->position; + } +} \ No newline at end of file diff --git a/src/CursorPositionOrigin.php b/src/PositionOrigin.php similarity index 82% rename from src/CursorPositionOrigin.php rename to src/PositionOrigin.php index 41dabea689623fabd2cc317b486c10212afbfd26..89b18f242ee193b6ba4d5a237a07e8ded0274e3d 100644 --- a/src/CursorPositionOrigin.php +++ b/src/PositionOrigin.php @@ -7,7 +7,7 @@ namespace Grifart\Mappi\Cursor; use MabeEnum\Enum; -class CursorPositionOrigin extends Enum +class PositionOrigin extends Enum { const FROM_LEFT = 0; const FROM_RIGHT = 1; diff --git a/src/SemanticCursor.php b/src/SemanticCursor.php index 2c618e1f5ed97802709f8ebf9641ea9577804dfd..68da21e005677794d25cd3fef2d96c3aa77fe908 100644 --- a/src/SemanticCursor.php +++ b/src/SemanticCursor.php @@ -19,7 +19,7 @@ namespace Grifart\Mappi\Cursor; class SemanticCursor implements ICursor { /** @var ICursor */ - private $cursor; + protected $cursor; /** * @param ICursor $cursor @@ -71,6 +71,22 @@ class SemanticCursor implements ICursor $this->moveFromEndTo(0); } + /** + * Scrolls to the END + */ + public function scrollToEnd() : int + { + return $this->scroll(ICursor::FETCH_REMAINING); + } + + /** + * Scroll to the BEGINNING + */ + public function scrollToBeginning() : int + { + return $this->scroll(ICursor::FETCH_FOREGOING); + } + // ---- FETCH ---- /** @@ -227,6 +243,12 @@ class SemanticCursor implements ICursor $this->cursor->moveBy($offset); } + /** {@inheritdoc} */ + public function scroll(int $offset) : int + { + return $this->cursor->scroll($offset); + } + /** {@inheritdoc} */ public function fetchRange(int $offset) : array { diff --git a/src/SemanticTrackedCursor.php b/src/SemanticTrackedCursor.php new file mode 100644 index 0000000000000000000000000000000000000000..52fd6e8077674e90a512733958cb1cd6f8945dc4 --- /dev/null +++ b/src/SemanticTrackedCursor.php @@ -0,0 +1,48 @@ +<?php declare(strict_types = 1); +/** + * This file is part of mappi/cursor. + */ + +namespace Grifart\Mappi\Cursor; + +/** + * Semantic cursor with tracked extensions + * @todo remove inheritance and use composition instead + * @todo add tests + * @package Grifart\Mappi\Cursor + */ +class SemanticTrackedCursor extends SemanticCursor +{ + /** @var TrackedCursor */ + protected $cursor; + + /** + * @param TrackedCursor $cursor + */ + public function __construct(TrackedCursor $cursor) + { + parent::__construct($cursor); + } + + public function getPosition() : Position + { + return $this->cursor->getPosition(); + } + + /** + * Get total number of rows in cursor + * Caution! This will scroll through cursor to the BEGINNING or to the END + * @return int the number of rows in cursor + */ + public function getTotal() : int + { + $fromLeft = $this->cursor->getPosition()->getOrigin()->is(PositionOrigin::FROM_LEFT); + if($fromLeft) { + $this->scrollToEnd(); + } else { + $this->scrollToBeginning(); + } + return $this->cursor->getPosition()->getPosition() - 1; + } + +} \ No newline at end of file diff --git a/src/TrackedCursor.php b/src/TrackedCursor.php index b4ddd27feac99a5004cc39a76c6913247d4bafc7..4352aca0c5f20eac9a72c0de126661135bd919af 100644 --- a/src/TrackedCursor.php +++ b/src/TrackedCursor.php @@ -7,7 +7,7 @@ namespace Grifart\Mappi\Cursor; class TrackedCursor implements ICursor { - /** @var CursorPosition */ + /** @var Position */ private $position; /** @var ICursor */ @@ -16,17 +16,17 @@ class TrackedCursor implements ICursor /** * Tip: if you are not sure that cursor will be in initial state, call * ->moveToBeginning() after initialization. - * @param ICursor $cursor Cursor in initial state (index=0) - * @param CursorPosition $initialPosition + * @param ICursor $cursor Cursor in initial state (index=0) + * @param Position $initialPosition */ - public function __construct(ICursor $cursor, CursorPosition $initialPosition) + public function __construct(ICursor $cursor, Position $initialPosition) { $this->cursor = $cursor; $this->position = $initialPosition; } /** - * @return CursorPosition + * @return Position */ public function getPosition() { @@ -47,18 +47,16 @@ class TrackedCursor implements ICursor { $this->cursor->moveTo($index); if ($this->cursor->isOnRecord()) { - $this->position->setPositionFromLeft( - $index - ); + $this->position = Position::fromLeft($index); return; } if ($index === 0) { - $this->position->setPositionFromLeft(0); // BEGINNING + $this->position = Position::fromLeft(0); // BEGINNING return; } if ($index > 0 ) { - $this->position->setPositionFromRight(0); // END + $this->position = Position::fromRight(0); // END return; } throw CursorException::cursorPosition_unknownError(); @@ -68,18 +66,16 @@ class TrackedCursor implements ICursor { $this->cursor->moveFromEndTo($index); if ($this->cursor->isOnRecord()) { - $this->position->setPositionFromRight( - $index - ); + $this->position = Position::fromRight($index); return; } if ($index === 0) { - $this->position->setPositionFromRight(0); // END + $this->position = Position::fromRight(0); // END return; } if ($index > 0) { - $this->position->setPositionFromLeft(0); // BEGINNING + $this->position = Position::fromLeft(0); // BEGINNING return; } throw CursorException::cursorPosition_unknownError(); @@ -87,18 +83,17 @@ class TrackedCursor implements ICursor public function moveBy(int $offset) { - // todo: use MOVE FORWARD n IN ... which returns number of rows read $this->cursor->moveBy($offset); if ($this->cursor->isOnRecord()) { - $this->position->movePositionBy($offset); + $this->position = Position::relativeTo($this->position, $offset); return; } if ($offset < 0) { - $this->position->setPositionFromLeft(0); // BEGINNING + $this->position = Position::fromLeft(0); // BEGINNING return; } if ($offset > 0) { - $this->position->setPositionFromRight(0); // END + $this->position = Position::fromRight(0); // END return; } if ($offset === 0) { @@ -107,6 +102,26 @@ class TrackedCursor implements ICursor throw CursorException::cursorPosition_unknownError(); } + public function scroll(int $offset) : int + { + $wasOnRecord = $this->isOnRecord(); + $rowsRead = $cursorDelta = $this->cursor->scroll($offset); + $forward = $offset >= 0; + + // scroll over the whole data-set + $scrolledOverWholeSet = $rowsRead > 0 && $wasOnRecord === FALSE && !$this->isOnRecord(); + $wasOnRecordAndHitEnd = $wasOnRecord === TRUE && !$this->isOnRecord(); + if($scrolledOverWholeSet || $wasOnRecordAndHitEnd) { + $cursorDelta++; + } + + $this->position = Position::relativeTo( + $this->position, + ($forward ? 1 : -1) * $cursorDelta + ); + return $rowsRead; + } + public function fetchRange(int $offset): array { $result = $this->cursor->fetchRange($offset); @@ -118,17 +133,18 @@ class TrackedCursor implements ICursor $count = count($result); $reachedEnd = $count < abs($offset); if(!$reachedEnd) { - $this->position->movePositionBy( + $this->position = Position::relativeTo( + $this->position, $count * ($forward ? 1 : -1) ); return $result; } if($forward) { - $this->position->setPositionFromRight(0); // right END + $this->position = Position::fromRight(0); // right END return $result; } else { - $this->position->setPositionFromLeft(0); // left END + $this->position = Position::fromLeft(0); // left END return $result; } } @@ -138,24 +154,24 @@ class TrackedCursor implements ICursor $row = $this->cursor->fetchOneAt($index); if ($row !== NULL) { if($index < 0) { - $this->position->setPositionFromRight(abs($index)); + $this->position = Position::fromRight(abs($index)); } else /* >= 0 */ { - $this->position->setPositionFromLeft($index); + $this->position = Position::fromLeft(abs($index)); } return $row; } if ($index === 0) { - $this->position->setPositionFromLeft(0); // BEGINNING + $this->position = Position::fromLeft(0); // BEGINNING return NULL; } if ($index < 0) { // went from right to left -> hit beginning - $this->position->setPositionFromLeft(0); + $this->position = Position::fromLeft(0); return NULL; } if ($index > 0) { // went left->right -> hit end - $this->position->setPositionFromRight(0); + $this->position = Position::fromRight(0); return NULL; } @@ -166,16 +182,16 @@ class TrackedCursor implements ICursor { // todo: content tests $row = $this->cursor->fetchOneBy($offset); if ($row !== NULL) { - $this->position->movePositionBy($offset); + $this->position = $this->position = Position::relativeTo($this->position, $offset); return $row; } if ($offset < 0) { - $this->position->setPositionFromLeft(0); // BEGINNING + $this->position = Position::fromLeft(0); // BEGINNING return NULL; } if ($offset > 0) { - $this->position->setPositionFromRight(0); // END + $this->position = Position::fromRight(0); // END return NULL; } if ($offset === 0) { diff --git a/tests/Cursor/CursorTest.phpt b/tests/Cursor/CursorTest.phpt index 2473cb48f3325b949d10139679e6ff9d9bc5eaaa..efa87419bec1c1088576f745155b23eb7829c03f 100644 --- a/tests/Cursor/CursorTest.phpt +++ b/tests/Cursor/CursorTest.phpt @@ -3,10 +3,10 @@ * @testCase */ -namespace Grifart\Mappi\Tests\Store\Cursor; +namespace Grifart\Mappi\Tests\Cursor; -use Grifart\Mappi\Cursor\ArrayCursorDriver; use Grifart\Mappi\Cursor\Cursor; +use Grifart\Mappi\Tests\Cursor\Driver\ArrayCursorDriver; use Grifart\Mappi\Cursor\ICursor; require_once __DIR__ . "/../bootstrap.php"; @@ -19,11 +19,6 @@ class CursorTest extends ICursorTest protected function setUp() { - //global $connection, $SQL_thousandRowsAscending; - //$connection->begin(); - - //$factory = new PostgresCursorFactory($connection); - //$this->uut = $factory->create($SQL_thousandRowsAscending, true); $driver = new ArrayCursorDriver(); $driver->createTestCursor("test", 1000); $this->uut = new Cursor( @@ -34,14 +29,6 @@ class CursorTest extends ICursorTest parent::setUp(); } - public function tearDown() - { -// global $connection; -// $connection->rollback(); - - parent::tearDown(); - } - } (new CursorTest())->run(); diff --git a/tests/Cursor/Driver/ArrayCursorDriver.php b/tests/Cursor/Driver/ArrayCursorDriver.php new file mode 100644 index 0000000000000000000000000000000000000000..8c29678094f03c149b3d737798a074e0f22d6fcd --- /dev/null +++ b/tests/Cursor/Driver/ArrayCursorDriver.php @@ -0,0 +1,222 @@ +<?php declare(strict_types = 1); +/** + * This file is part of mappi/cursor. + */ + +namespace Grifart\Mappi\Tests\Cursor\Driver; +use Grifart\Mappi\Cursor\CursorException; +use Grifart\Mappi\Cursor\Driver\ICursorDriver; + +/** + * Array Cursor simulates scrolling cursor as implemented in PostgreSQL 9.5. + * + * For more information see ICursor. + * @see ICursor + * @package Grifart\Mappi\Cursor + */ +class ArrayCursorDriver implements ICursorDriver +{ + private $cursors = []; + private $indexes = []; + + /** + * Creates test cursor with given name + * @param string $name + * @param int $length + */ + public function createTestCursor(string $name, int $length) + { + if(isset($this->cursors[$name])) { + throw new CursorException("Cursor with name $name already exists"); + } + + $this->cursors[$name] = $this->generateCursorData($length); + + $this->indexes[$name] = 0; + } + + /** + * Closes test cursor + * @param string $name + * @return bool + */ + public function close(string $name) : bool + { + unset($this->cursors[$name]); + unset($this->indexes[$name]); + return TRUE; + } + + /** + * Cursor data is table with one column with name "n" which contains current row index as value + * @param int $length length of values + * @return array + */ + private function generateCursorData(int $length): array + { + $data = []; + $data[0] = null; + $data[$length+1] = null; + for($i = 1; $i <= $length; $i++) { + $data[$i] = ["n" => $i]; + } + return $data; + } + + /** + * Returns END position index from LEFT + * @param string $name The cursor name + * @return int + */ + private function getMaxIndex(string $name) { + return count($this->cursors[$name])-1; + } + + /** + * Normalizes index to be between BEGINNING and END of cursor + * @param string $name The cursor name + * @param int $index The index to normalize + */ + private function normalizeIndex(string $name, int &$index) + { + if($index < 0) { + $index = 0; + } + $max = $this->getMaxIndex($name); + if($index > $max) { + $index = $max; + } + } + + /** + * Moves internal cursor pointer to position from BEGINNING + * @param string $name The cursor name + * @param int $index The index from the BEGINNING; index is normalized before move + */ + private function pointerMoveToFromBeginning(string $name, int $index) + { + $this->normalizeIndex($name, $index); + $this->indexes[$name] = $index; + } + + /** + * Moves internal cursor pointer to position from the END + * @param string $name The cursor name + * @param int $index The index from the ENG; + */ + private function pointerMoveToFromEnd(string $name, int $index) + { + $indexFromLeft = $this->getMaxIndex($name) - $index; + $this->normalizeIndex($name, $indexFromLeft); + + $this->moveFromBeginningTo($name, $indexFromLeft); + } + + private function getCurrentIndexFromBeginning(string $name) : int + { + return $this->indexes[$name]; + } + + private function retrieveCurrentRow(string $name) { + return $this->cursors[$name][$this->getCurrentIndexFromBeginning($name)]; + } + + public function moveFromBeginningTo(string $name, int $index) : bool + { + if ($index < 0) { + throw new \InvalidArgumentException("Negative index not supported. Use moveFromEndTo() instead."); + } + $this->pointerMoveToFromBeginning($name, $index); + return !!$this->retrieveCurrentRow($name); + } + + public function moveFromEndTo(string $name, int $index) : bool + { + if ($index < 0) { + throw new \InvalidArgumentException("Negative index not supported. Use moveTo() instead."); + } + $this->pointerMoveToFromEnd($name, $index); + return !!$this->retrieveCurrentRow($name); + } + + public function moveBy(string $name, int $offset) : bool + { + $i = $this->getCurrentIndexFromBeginning($name); + $indexFromLeft = $i + $offset; + $this->normalizeIndex($name, $indexFromLeft); + $this->moveFromBeginningTo($name, $indexFromLeft); + return !!$this->retrieveCurrentRow($name); + } + + public function scroll(string $name, int $offset) : int + { + if($offset === 0) return 0; + return count($this->fetchRange($name, $offset)); + } + + // ----------------- FETCH --------------------- + + public function fetchRange(string $name, int $offset) : array + { + $currentKey = $this->getCurrentIndexFromBeginning($name); + if($offset === self::FETCH_FOREGOING) { + $finalKey = PHP_INT_MIN; + } elseif ($offset === self::FETCH_REMAINING) { + $finalKey = PHP_INT_MAX; + } else { + $finalKey = $currentKey + $offset; + } + $this->normalizeIndex($name, $finalKey); + + $data = []; + if($offset === 0) { + $v = $this->retrieveCurrentRow($name); + if($v !== NULL) { + $data[] = $v; + } + return $data; + } + + if($finalKey < $currentKey) { // backwards + for($i = $currentKey-1; $i >= $finalKey; $i--) { + $this->moveFromBeginningTo($name, $i); + $v = $this->retrieveCurrentRow($name); + if($v === NULL) {break;} + $data[] = $v; + } + } else { + for($i = $currentKey+1; $i <= $finalKey; $i++) { + $this->moveFromBeginningTo($name, $i); + $v = $this->retrieveCurrentRow($name); + if($v === NULL) {break;} + $data[] = $v; + } + } + return $data; + } + + public function fetchOneAt(string $name, int $index) + { + if($index < 0) { + $this->moveFromEndTo($name, abs($index)); + } else { + $this->moveFromBeginningTo($name, abs($index)); + } + $data = $this->fetchRange($name, 0); + if(count($data) === 0) { + return NULL; + } + return $data[0]; + } + + public function fetchOneBy(string $name, int $offset) + { + $this->moveBy($name, $offset); + $data = $this->fetchRange($name, 0); + if(count($data) === 0) { + return NULL; + } + return $data[0]; + } + +} \ No newline at end of file diff --git a/tests/Cursor/ICursorTest.php b/tests/Cursor/ICursorTest.php index 5c45a8213c1a744fe27bb95391e0f0ad965cb0ca..ec82cb1d5a029efd9882bee30ec7dbe1a759c71a 100644 --- a/tests/Cursor/ICursorTest.php +++ b/tests/Cursor/ICursorTest.php @@ -3,7 +3,7 @@ * @testCase */ -namespace Grifart\Mappi\Tests\Store\Cursor; +namespace Grifart\Mappi\Tests\Cursor; use Grifart\Mappi\Cursor\ICursor; use Grifart\Mappi\Tests\Store\BaseTest; @@ -15,7 +15,7 @@ use Tester\Assert; * This file is using Given-When-Then naming convention. * @link http://martinfowler.com/bliki/GivenWhenThen.html * - * @package Grifart\Mappi\Tests\Store\Cursor + * @package Grifart\Mappi\Tests\Cursor */ abstract class ICursorTest extends BaseTest { @@ -183,6 +183,49 @@ abstract class ICursorTest extends BaseTest $this->uut->moveBy(-1); Assert::null($this->helper_fetchCurrentSingle()); } + + // scroll() + public function test_givenInitialPosition_whenMoveToEnd_thenGetTotalCount() + { + Assert::same(1000, $this->uut->scroll(ICursor::FETCH_REMAINING)); + Assert::null($this->helper_fetchCurrentSingle()); + } + + public function test_givenFifthPosition_whenMoveToEndByIncrements_thenGetRestOfCount() + { + $this->uut->moveTo(5); + Assert::same(5, $this->uut->scroll(5)); + Assert::same(10, $this->helper_fetchCurrentSingle()); + + Assert::same(989, $this->uut->scroll(989)); + Assert::same(999, $this->helper_fetchCurrentSingle()); + + Assert::same(1, $this->uut->scroll(1)); + Assert::same(1000, $this->helper_fetchCurrentSingle()); + + Assert::same(0, $this->uut->scroll(1)); + Assert::null($this->helper_fetchCurrentSingle()); + + Assert::same(0, $this->uut->scroll(1)); + } + + public function test_givenFifthPosition_whenScrollZero_thenGetOne() + { + $this->uut->moveTo(5); + Assert::same(0, $this->uut->scroll(0)); + } + + public function test_givenFifthPosition_whenMoveToBeginning_thenGetForegoingNumberOfRows() + { + $this->uut->moveTo(5); + + Assert::same(4, $this->uut->scroll(ICursor::FETCH_FOREGOING)); + } + + public function test_givenBeginningPosition_whenMoveToBeginning_thenGetZero() + { + Assert::same(0, $this->uut->scroll(ICursor::FETCH_FOREGOING)); + } // fetchOneAt() diff --git a/tests/Cursor/PostgresCursorTest.phpt b/tests/Cursor/PostgresCursorTest.phpt index 716babdf4e5b08fc114b0b526c76856c189fdb9a..97e8a0c4ff5a2600cad1925dabba2a3cfc5a00f0 100644 --- a/tests/Cursor/PostgresCursorTest.phpt +++ b/tests/Cursor/PostgresCursorTest.phpt @@ -3,11 +3,11 @@ * @testCase */ -namespace Grifart\Mappi\Tests\Store\Cursor; +namespace Grifart\Mappi\Tests\Cursor; use Dibi\DriverException; +use Grifart\Mappi\Cursor\Driver\PostgresCursorFactory; use Grifart\Mappi\Cursor\ICursor; -use Grifart\Mappi\Cursor\PostgresCursorFactory; use Tester\Environment; require_once __DIR__ . "/../bootstrap.php"; diff --git a/tests/Cursor/SemanticCursorIntegration.phpt b/tests/Cursor/SemanticCursorIntegration.phpt index 5cef2c7d38778b9b5f46f377606268bcc5bc6cc1..eef253da3fd04e483a8c4b0de3f3d6ec446bd929 100644 --- a/tests/Cursor/SemanticCursorIntegration.phpt +++ b/tests/Cursor/SemanticCursorIntegration.phpt @@ -3,9 +3,8 @@ * @testCase */ -namespace Grifart\Mappi\Tests\Store\Cursor; +namespace Grifart\Mappi\Tests\Cursor; -use Grifart\Mappi\Cursor\ArrayCursorDriver; use Grifart\Mappi\Cursor\Cursor; use Grifart\Mappi\Cursor\SemanticCursor; use Mockery; @@ -17,7 +16,7 @@ require_once __DIR__ . "/ICursorTest.php"; * Behavioral test for SemanticCursor using mocking. * @link http://docs.mockery.io/en/latest/ * - * @package Grifart\Mappi\Tests\Store\Cursor + * @package Grifart\Mappi\Tests\Cursor */ class SemanticCursorIntegrationTest extends ICursorTest { @@ -27,11 +26,7 @@ class SemanticCursorIntegrationTest extends ICursorTest protected function setUp() { -// global $connection, $SQL_thousandRowsAscending; -// $connection->begin(); - - - $driver = new ArrayCursorDriver(); + $driver = new Driver\ArrayCursorDriver(); $driver->createTestCursor("test", 1000); $cursor = new Cursor( $driver, @@ -44,14 +39,6 @@ class SemanticCursorIntegrationTest extends ICursorTest parent::setUp(); } - public function tearDown() - { -// global $connection; -// $connection->rollback(); - - parent::tearDown(); - } - } (new SemanticCursorIntegrationTest())->run(); diff --git a/tests/Cursor/SemanticCursorTest.phpt b/tests/Cursor/SemanticCursorTest.phpt index 329f7edbc7574f5751f924f7acbdb2efe3dac227..7b05be6741bf8d817d2fa81481c8783c85bfe191 100644 --- a/tests/Cursor/SemanticCursorTest.phpt +++ b/tests/Cursor/SemanticCursorTest.phpt @@ -3,7 +3,7 @@ * @testCase */ -namespace Grifart\Mappi\Tests\Store\Cursor; +namespace Grifart\Mappi\Tests\Cursor; use Grifart\Mappi\Cursor\CursorException; use Grifart\Mappi\Cursor\ICursor; @@ -19,7 +19,7 @@ require_once __DIR__ . "/ICursorTest.php"; * Behavioral test for SemanticCursor using mocking. * @link http://docs.mockery.io/en/latest/ * - * @package Grifart\Mappi\Tests\Store\Cursor + * @package Grifart\Mappi\Tests\Cursor */ class SemanticCursorTest extends BaseTest { @@ -137,6 +137,22 @@ class SemanticCursorTest extends BaseTest $this->uut->moveToEnd(); } + // ---- the extension - scroll* ---- + + public function test_scrollBeginning() { + $this->mockedCursor->shouldReceive("scroll") + ->with(ICursor::FETCH_FOREGOING)->once()->andReturn(5); + + Assert::same(5, $this->uut->scrollToBeginning()); + } + + public function test_scrollEnd() { + $this->mockedCursor->shouldReceive("scroll") + ->with(ICursor::FETCH_REMAINING)->once()->andReturn(5); + + Assert::same(5, $this->uut->scrollToEnd()); + } + // ---- the extension - fetch* ---- public function test_fetchNext() diff --git a/tests/Cursor/TrackedCursorTest.phpt b/tests/Cursor/TrackedCursorTest.phpt index 0511173a71b4903bc7b204e7cacebf8b60e990b7..1e3e21bd644a8d077f8aa10388d814de92f13a33 100644 --- a/tests/Cursor/TrackedCursorTest.phpt +++ b/tests/Cursor/TrackedCursorTest.phpt @@ -3,14 +3,13 @@ * @testCase */ -// TODO: moveBy() ->isOnRecord() ->moveBy() ->isOnRecord() (now I see state before and after command) // TODO: Maybe construct Cursor class in TrackedCursor constructor? -namespace Grifart\Mappi\Tests\Store\Cursor; +namespace Grifart\Mappi\Tests\Cursor; -use Grifart\Mappi\Cursor\ArrayCursorDriver; use Grifart\Mappi\Cursor\Cursor; -use Grifart\Mappi\Cursor\CursorPosition; +use Grifart\Mappi\Cursor\ICursor; +use Grifart\Mappi\Cursor\Position; use Grifart\Mappi\Cursor\TrackedCursor; use Tester\Assert; @@ -19,19 +18,38 @@ require_once __DIR__ . "/ICursorTest.php"; class TrackedCursorTest extends ICursorTest { - /** @link https://en.wikipedia.org/wiki/42_(number)#Hitchhiker.27s_Guide_to_the_Galaxy */ - const THE_MAGIC_NUMBER = 42; - /** @var TrackedCursor */ protected $uut; + // test with underlying postgres driver +// protected function setUp() +// { +// global $connection, $SQL_thousandRowsAscending; +// try{ +// $connection->begin(); +// +// $factory = new PostgresCursorFactory($connection); +// $cursor = $factory->create($SQL_thousandRowsAscending, true); +// $this->uut = new TrackedCursor($cursor, Position::fromLeft(0)); +// } catch (DriverException $e) { +// Environment::skip("It looks like you haven't properly set-up dibi connection to PostgreSQL. Check tests/bootstrap.php"); +// } +// +// parent::setUp(); +// } +// +// public function tearDown() +// { +// global $connection; +// $connection->rollback(); +// +// parent::tearDown(); +// } + protected function setUp() { -// global $connection, $SQL_thousandRowsAscending; -// $connection->begin(); -// $factory = new PostgresCursorFactory($connection); - $driver = new ArrayCursorDriver(); + $driver = new Driver\ArrayCursorDriver(); $driver->createTestCursor("test", 1000); $cursor = new Cursor( $driver, @@ -40,19 +58,11 @@ class TrackedCursorTest extends ICursorTest $this->uut = new TrackedCursor( $cursor, - CursorPosition::fromLeft(0) + Position::fromLeft(0) ); parent::setUp(); } - public function tearDown() - { -// global $connection; -// $connection->rollback(); - - parent::tearDown(); - } - private function assertPosition(string $position) { Assert::equal($position, (string)$this->uut->getPosition()); @@ -161,6 +171,81 @@ class TrackedCursorTest extends ICursorTest $this->assertPosition("left:0"); } + public function test_getPosition_left_scroll() + { + Assert::same(0, $this->uut->scroll(-5)); + $this->assertPosition("left:0"); + + Assert::same(0, $this->uut->scroll(0)); + $this->assertPosition("left:0"); + + Assert::same(1, $this->uut->scroll(1)); + $this->assertPosition("left:1"); + + Assert::same(0, $this->uut->scroll(0)); + $this->assertPosition("left:1"); + + Assert::same(0, $this->uut->scroll(-1)); + $this->assertPosition("left:0"); + + Assert::same(1, $this->uut->scroll(1)); + $this->assertPosition("left:1"); + + Assert::same(999, $this->uut->scroll(999)); + Assert::same(1000, $this->helper_fetchCurrentSingle()); // regression + $this->assertPosition("left:1000"); + + Assert::same(0, $this->uut->scroll(0)); + $this->assertPosition("left:1000"); + + Assert::same(0, $this->uut->scroll(1)); + $this->assertPosition("left:1001"); + + Assert::same(0, $this->uut->scroll(1)); + $this->assertPosition("left:1001"); + + Assert::same(1, $this->uut->scroll(-1)); + $this->assertPosition("left:1000"); + + Assert::same(0, $this->uut->scroll(1)); + $this->assertPosition("left:1001"); + + Assert::same(1000, $this->uut->scroll(ICursor::FETCH_FOREGOING)); + $this->assertPosition("left:0"); + + Assert::same(1000, $this->uut->scroll(ICursor::FETCH_REMAINING)); + $this->assertPosition("left:1001"); + + Assert::same(1000, $this->uut->scroll(ICursor::FETCH_FOREGOING)); + $this->assertPosition("left:0"); + } + + public function test_getPosition_right_scroll() + { + $this->uut->moveFromEndTo(0); + $this->assertPosition("right:0"); + + $this->uut->scroll(1); + $this->assertPosition("right:0"); + + $this->uut->scroll(0); + $this->assertPosition("right:0"); + + $this->uut->scroll(-5); + $this->assertPosition("right:5"); + + $this->uut->scroll(-995); + $this->assertPosition("right:1000"); + Assert::equal(1, $this->helper_fetchCurrentSingle()); // regression + + $this->uut->scroll(-1); + $this->assertPosition("right:1001"); + Assert::null($this->helper_fetchCurrentSingle()); // regression + + $this->uut->scroll(-1); + $this->assertPosition("right:1001"); + } + public function test_getPosition_left_fetchRange() { $this->assertPosition("left:0");