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