From f9d8a37c07d7f046069868ca0739753f690bed99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kucha=C5=99?= <honza.kuchar@grifart.cz> Date: Fri, 6 May 2016 20:31:19 +0200 Subject: [PATCH] First version of ICursor and it's implementation FastCursor --- composer.json | 8 +- gulpfile.js | 4 + src/DibiDataStore.php | 8 - src/PostgresDriver/CursorException.php | 27 ++ src/PostgresDriver/FastCursor.php | 188 ++++++++++++++ src/PostgresDriver/ICursor.php | 104 ++++++++ tests/BaseTest.php | 4 +- .../Store/PostgresDriver/FastCursorTest.phpt | 239 ++++++++++++++++++ tests/bootstrap.php | 22 ++ tests/php-windows.ini | 1 + 10 files changed, 593 insertions(+), 12 deletions(-) delete mode 100644 src/DibiDataStore.php create mode 100644 src/PostgresDriver/CursorException.php create mode 100644 src/PostgresDriver/FastCursor.php create mode 100644 src/PostgresDriver/ICursor.php create mode 100644 tests/Store/PostgresDriver/FastCursorTest.phpt diff --git a/composer.json b/composer.json index 1905ca7..0fe3128 100644 --- a/composer.json +++ b/composer.json @@ -1,10 +1,14 @@ { "name": "mappi/store", "require": { - "php": ">=7.0.0" + "php": ">=7.0.0", + "dibi/dibi": "^3.0", + "tracy/tracy": "^2.3" }, "require-dev": { - "nette/tester": "^1.7" + "nette/tester": "^1.7", + "mockery/mockery": "^0.9.4", + "kdyby/tester-extras": "dev-master@dev" }, "authors": [ { diff --git a/gulpfile.js b/gulpfile.js index 3526e36..b84e982 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -14,3 +14,7 @@ gulp.task( gulp.task("composer:install", shell.task([ "composer install" ])); + +gulp.task("tests", shell.task([ + "composer install" +])); \ No newline at end of file diff --git a/src/DibiDataStore.php b/src/DibiDataStore.php deleted file mode 100644 index 1bca285..0000000 --- a/src/DibiDataStore.php +++ /dev/null @@ -1,8 +0,0 @@ -<?php declare(strict_types = 1); - -/** - * This file is part of mappi/store. - */ -class DibiDataStore -{ -} \ No newline at end of file diff --git a/src/PostgresDriver/CursorException.php b/src/PostgresDriver/CursorException.php new file mode 100644 index 0000000..268f278 --- /dev/null +++ b/src/PostgresDriver/CursorException.php @@ -0,0 +1,27 @@ +<?php declare(strict_types = 1); +/** + * This file is part of mappi/store. + */ + +namespace Grifart\Mappi\Store\PostgresDriver; + +use Exception; + +class CursorException extends \LogicException +{ + const MESSAGE_NO_DATA_TO_FETCH = "There was not data to fetch. Haven't you reached end of cursor?"; + + /** + * @inheritDoc + */ + public function __construct($message, $code=0, \Exception $previous = null) + { + parent::__construct($message, $code, $previous); + } + + public static function noDataToFetch() + { + return new static(self::MESSAGE_NO_DATA_TO_FETCH); + } + +} \ No newline at end of file diff --git a/src/PostgresDriver/FastCursor.php b/src/PostgresDriver/FastCursor.php new file mode 100644 index 0000000..36c68bf --- /dev/null +++ b/src/PostgresDriver/FastCursor.php @@ -0,0 +1,188 @@ +<?php declare(strict_types = 1); +/** + * This file is part of mappi/store. + */ + +namespace Grifart\Mappi\Store\PostgresDriver; + +use Dibi\Connection; + +class FastCursor implements ICursor +{ + + /** @var Connection */ + private $connection; + + /** @var string */ + private $cursorName; + + /** @var string */ + private $sql; + + // todo: require only connection and name + // todo: cursor declaration will be in competence of factory + // todo: close + public function __construct(Connection $connection, string $sql, bool $scroll = true) + { + $this->connection = $connection; + + // declare cursor + $connection->query( + "DECLARE %n %SQL CURSOR FOR (%SQL)", + $this->cursorName = $this->generateCursorName(), + $scroll ? "SCROLL" : "NO SCROLL", + $this->sql = $sql + ); + } + + public function getConnection() : Connection + { + return $this->connection; + } + + public function getCursorName() : string + { + return $this->cursorName; + } + + private function generateCursorName() : string + { + return uniqid(); + } + + public function moveTo(int $index) + { + $this->getConnection()->query( + "MOVE ABSOLUTE %i IN %n", + $index, + $this->getCursorName() + ); + } + + public function moveBy(int $rows) + { + $this->getConnection()->query( + "MOVE RELATIVE %i IN %n", + $rows, + $this->getCursorName() + ); + } + + public function moveToBeginning() + { + $this->getConnection()->query( + "MOVE ABSOLUTE 0 IN %n;", + $this->getCursorName() + ); + } + + public function moveToFirst() + { + $this->getConnection()->query( + "MOVE FIRST IN %n;", // == ABSOLUTE 1 + $this->getCursorName() + ); + } + + public function moveToLast() + { + $this->getConnection()->query( + "MOVE LAST IN %n", // == ABSOLUTE -1 + $this->getCursorName() + ); + } + + public function moveToEnd() + { + $this->getConnection()->query( + "MOVE ABSOLUTE -1 IN %n; MOVE NEXT IN %n;", + $this->getCursorName(), + $this->getCursorName() + ); + } + + public function fetch(int $rows) + { + $forward = $rows >= 0; + $rows = abs($rows); + return $this->connection->query( + $forward ? "FETCH FORWARD %i FROM %n" : "FETCH BACKWARD %i FROM %n", + $rows, + $this->getCursorName() + )->fetchAll(); + } + + public function fetchNext() + { + // todo: throw and exception when 0 undefined + $row = $this->fetch(1); + if(isset($row[0])) { + return $row[0]; + } + return FALSE; + } + + public function fetchCurrent() + { + return $this->fetchOneBy(0); + } + + public function fetchNextSingle() + { + $row = $this->fetchNext(); + if($row === FALSE) { + throw CursorException::noDataToFetch(); + } + return current( + $row->toArray() + ); + } + + public function fetchCurrentSingle() + { + $row = $this->fetchCurrent(); + if($row === FALSE) { + throw CursorException::noDataToFetch(); + } + return current( + $row->toArray() + ); + } + + public function fetchOneAt(int $index) + { + return $this->connection->query( + "FETCH ABSOLUTE %i FROM %n", + $index, + $this->getCursorName() + )->fetch(); + } + + public function fetchOneBy(int $rows) + { + return $this->connection->query( + "FETCH RELATIVE %i FROM %n", + $rows, + $this->getCursorName() + )->fetch(); + } + + public function fetchRemaining() + { + return $this->connection->query( + "FETCH FORWARD ALL FROM %n", + $this->getCursorName() + )->fetchAll(); + } + + public function fetchForegoing() + { + return $this->connection->query( + "FETCH BACKWARD ALL FROM %n", + $this->getCursorName() + )->fetchAll(); + } + + + +} \ No newline at end of file diff --git a/src/PostgresDriver/ICursor.php b/src/PostgresDriver/ICursor.php new file mode 100644 index 0000000..8f2aa04 --- /dev/null +++ b/src/PostgresDriver/ICursor.php @@ -0,0 +1,104 @@ +<?php +/** + * This file is part of mappi/store. + */ +namespace Grifart\Mappi\Store\PostgresDriver; + +/** + * Represents PostgreSQL cursor + * @link http://www.postgresql.org/docs/9.5/static/plpgsql-cursors.html + * @link http://www.postgresql.org/docs/8.1/static/sql-fetch.html + * @link http://www.postgresql.org/docs/8.1/static/sql-move.html + * @package Grifart\Mappi\Store\PostgresDriver + */ +interface ICursor +{ + public function getCursorName() : string; + + /** + * @param int $rows + * @return void + */ + public function moveBy(int $rows); + + /** + * @param int $index + * @return void + */ + public function moveTo(int $index); + + /** + * Move cursor on the last row + * @see fetchCurrent() + * @see fetchCurrentSingle() + * @return void + */ + public function moveToLast(); + + /** + * Move cursor on the first row (index=1) + * @see fetchCurrent() + * @see fetchCurrentSingle() + * @return void + */ + public function moveToFirst(); + + /** + * Moves cursor to position BEFORE first row (index=0) + * @see fetchNext() + * @see fetchNextSingle() + * @return void + */ + public function moveToBeginning(); + + /** + * Moves cursor to position AFTER last row + * @return void + */ + public function moveToEnd(); + + /** + * Fetch the next/previous count rows. FORWARD 0 re-fetches the current row. + * @param int $rows + * @return array + */ + public function fetch(int $rows); + + /** + * @return array the row + */ + public function fetchNext(); + public function fetchCurrent(); + + /** + * @return mixed the value in first column + */ + public function fetchNextSingle(); + public function fetchCurrentSingle(); + + /** + * Fetch the count'th row of the query, or the abs(count)'th row from the end if count is negative. Position before first row or after last row if count is out of range; in particular, ABSOLUTE 0 positions before the first row. + * @param int $index + * @return array + */ + public function fetchOneAt(int $index); + + /** + * Fetch the count'th succeeding row, or the abs(count)'th prior row if count is negative. RELATIVE 0 re-fetches the current row, if any. + * @param int $rows + * @return array + */ + public function fetchOneBy(int $rows); + + /** + * Fetch all remaining rows. + * @return array of remaining rows + */ + public function fetchRemaining(); + + /** + * Fetch all prior rows (scanning backwards). + * @return array of foregoing rows + */ + public function fetchForegoing(); +} \ No newline at end of file diff --git a/tests/BaseTest.php b/tests/BaseTest.php index ca893e5..1b7e4c4 100644 --- a/tests/BaseTest.php +++ b/tests/BaseTest.php @@ -4,7 +4,7 @@ * Copyright (c) 2015 Grifart spol. s r.o. (https://grifart.cz) */ -namespace GrifartTests; +namespace Grifart\Mappi\Tests\Store; use Mockery; use Tester; @@ -22,7 +22,7 @@ class BaseTest extends Tester\TestCase parent::tearDown(); // suppress no assert executed when mocking - if (count(\Mockery::getContainer()->getMocks()) > 0) { + if (count(Mockery::getContainer()->getMocks()) > 0) { Tester\Assert::true(true); } diff --git a/tests/Store/PostgresDriver/FastCursorTest.phpt b/tests/Store/PostgresDriver/FastCursorTest.phpt new file mode 100644 index 0000000..9066b2f --- /dev/null +++ b/tests/Store/PostgresDriver/FastCursorTest.phpt @@ -0,0 +1,239 @@ +<?php +/** + * @testCase + */ + +namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver; + +use Grifart\Mappi\Store\PostgresDriver\BasicCursor; +use Grifart\Mappi\Store\PostgresDriver\CursorException; +use Grifart\Mappi\Store\PostgresDriver\FastCursor; +use Grifart\Mappi\Tests\Store\BaseTest; +use Tester\Assert; +use Tester\Environment; + +require_once __DIR__ . "/../../bootstrap.php"; + +class FastCursorTest extends BaseTest +{ + /** @var FastCursor */ + private $uut; + + /** + * @inheritDoc + */ + 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(); + } + + + // initial position + public function test_givenCursor_whenGetCurrentRow_thenMustFail() + { + // must fail because initial position is 0 + Assert::exception(function() { + $this->uut->fetchCurrentSingle(); + }, CursorException::class); + } + + public function test_givenInitialPosition_whenFetch_thenGetFirstRow() + { + Assert::same(1, $this->uut->fetchNextSingle()); + } + + // fetch( x == 0 ) + public function test_givenSecondPosition_whenFetch0_thenGetCurrentRow() + { + // head: . + // index: 0 1 2 3 4 + // value: 1 2 3 4 + $this->uut->moveTo(3); + Assert::same(3, $this->uut->fetchCurrentSingle()); + + $result = $this->uut->fetch(0); // rows backwards + read current + + Assert::count(1, $result); + Assert::same(3, current($result[0]->toArray())); + } + + // fetch( x > 0 ) + public function test_givenSecondPosition_whenFetchForward_thenGetRowsAfterCursor() + { + // head: . + // index: 0 1 2 3 4 + // value: 1 2 3 4 + $this->uut->moveTo(3); + Assert::same(3, $this->uut->fetchCurrentSingle()); + + // head: . + // index: 0 1 2 3 4 5 6 + // value: 1 2 3 4 5 6 + $result = $this->uut->fetch(2); // rows backwards + read current + // todo: split into fetch decorator for those special fetch* methods + + Assert::count(2, $result); + Assert::same(4, current($result[0]->toArray())); + Assert::same(5, current($result[1]->toArray())); + Assert::same(5, $this->uut->fetchCurrentSingle()); + } + + // fetch( x < 0 ) + public function test_givenSecondPosition_whenFetchBackwards_thenGetRowsBeforeCursor() + { + // head: . + // index: 0 1 2 3 4 + // value: 1 2 3 4 + $this->uut->moveTo(3); + Assert::same(3, $this->uut->fetchCurrentSingle()); + + // head: . + // index: 0 1 2 3 4 + // value: 1 2 3 4 + $result = $this->uut->fetch(-2); // rows backwards + read current + // todo: split into fetch decorator for those special fetch* methods + + Assert::count(2, $result); + Assert::same(2, current($result[0]->toArray())); + Assert::same(1, current($result[1]->toArray())); + Assert::same(1, $this->uut->fetchCurrentSingle()); + } + + + // moveToBeginning() + public function test_givenEndPosition_whenMoveToTheBeginning_thenFetchingNextRowWillBeFirstRow() + { + // Arrange + $this->uut->moveToLast(); + + // Act + $this->uut->moveToBeginning(); + + //Assert + Assert::same(1, $this->uut->fetchNextSingle()); + } + + + // moveToFirst() + public function test_givenEndPosition_whenMoveToTheFirst_thenFetchingCurrentWillBeFirstRow() + { + // Arrange + $this->uut->moveToLast(); + + // Act + $this->uut->moveToFirst(); + + //Assert + Assert::same(1, $this->uut->fetchCurrentSingle()); + } + + + // moveToEnd() + public function test_givenInitialPosition_whenMoveToEnd_thenFetchingOneBacwardsWillBeTheLastRow() + { + $this->uut->moveToEnd(); + + Assert::exception(function() { + $this->uut->fetchCurrentSingle(); + }, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH); + $data = $this->uut->fetch(-1); + Assert::count(1, $data); + Assert::same(1000, current($data[0]->toArray())); + } + + + // moveToLast() + public function test_givenInitialPosition_whenMoveToLast_thenFetchingCurrentWillBeLastRow() + { + $this->uut->moveToLast(); + Assert::same(1000, $this->uut->fetchCurrentSingle()); + Assert::exception(function() { + $this->uut->fetchNextSingle(); + }, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH); + } + + + // moveBy() + public function test_givenInitialPosition_whenMoveBy2_thenGetSecondValue() + { + $this->uut->moveBy(2); + Assert::same(2, $this->uut->fetchCurrentSingle()); + } + + public function test_givenSecondPosition_whenMoveOneBack_thenGetFirstValue() + { + $this->uut->moveTo(2); + $this->uut->moveBy(-1); + Assert::same(1, $this->uut->fetchCurrentSingle()); + } + + + // fetchOneAt() + public function test_givenInitialPosition_whenFetch5_thenGetFive() + { + $data = $this->uut->fetchOneAt(5); + Assert::same(5, current($data->toArray())); + } + + public function test_givenNonInitialPosition_whenFetch5_thenGetFive() + { + $this->uut->moveTo(234); // wherever + + $data = $this->uut->fetchOneAt(5); + Assert::same(5, current($data->toArray())); + } + + // fetchRemaining() + public function test_given5BeforeEndPosition_whenFetchRemaining_thenGetLastFile() + { + $this->uut->moveTo(995); + + $result = $this->uut->fetchRemaining(); + + Assert::count(5, $result); + Assert::same(996, current($result[0]->toArray())); + Assert::same(997, current($result[1]->toArray())); + Assert::same(998, current($result[2]->toArray())); + Assert::same(999, current($result[3]->toArray())); + Assert::same(1000, current($result[4]->toArray())); + + Assert::exception(function() { + $this->uut->fetchCurrentSingle(); + }, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH); + } + + + // fetchRemaining() + public function test_given5AfterStart_whenFetchForegoing_thenGetFirstFive() + { + $this->uut->moveTo(6); + + $result = $this->uut->fetchForegoing(); + + Assert::count(5, $result); + Assert::same(5, current($result[0]->toArray())); + Assert::same(4, current($result[1]->toArray())); + Assert::same(3, current($result[2]->toArray())); + Assert::same(2, current($result[3]->toArray())); + Assert::same(1, current($result[4]->toArray())); + + Assert::exception(function() { + $this->uut->fetchCurrentSingle(); + }, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH); + } + +} + +(new FastCursorTest())->run(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ce2a8b7..875931b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,6 +11,28 @@ if (!class_exists('Tester\Assert')) { exit(1); } +require __DIR__ . "/BaseTest.php"; + +$SQL_thousandRowsAscending = <<<SQL +WITH RECURSIVE t(n) AS ( + SELECT 1 + UNION ALL + SELECT n+1 FROM t +) +SELECT n FROM t LIMIT 1000 +SQL; + +$connection = new \Dibi\Connection( + [ + "driver" => "postgre", + "host" => "localhost", + "dbname" => "mappi-store-test", + "port" => "5434", + "user" => "postgres", + "password"=>"toor" + ] +); + Kdyby\TesterExtras\Bootstrap::setup(__DIR__); Tracy\Debugger::$logDirectory = __DIR__ . '/logs'; diff --git a/tests/php-windows.ini b/tests/php-windows.ini index 62fd0c6..094c13d 100644 --- a/tests/php-windows.ini +++ b/tests/php-windows.ini @@ -1,5 +1,6 @@ [PHP] extension_dir = "./ext" +extension = php_pgsql.dll ;[Zend] ;zend_extension=php_xdebug-2.3.2-5.6-vc11-nts-x86_64.dll -- GitLab