diff --git a/doc/result.texy b/doc/result.texy index 8c13a586..a012de9f 100644 --- a/doc/result.texy +++ b/doc/result.texy @@ -50,3 +50,16 @@ echo $row->age; echo $row->getNthField(0); // prints name \-- + + +Buffering +========= + +Some database drivers do not support rewinding or seeking the result. I.e. you cannot iterate over the result multiple times. Similarly, you cannot use `seek()` method to skip some rows. Dbal's emulated buffering comes to solve this for you. The relevant drivers automatically enable emulated buffering. You can disable or enable it for particular `Result` instances. + +/--php +$result = $connection->query('...')->buffered(); // enable emulated buffering +$result->unbuffered(); // disable the emulated buffering +\-- + +If the unbuffered Result was already partially consumed, enabling buffering does nothing and Result will potentially throw an exception when rewinded or seeked. If the buffered Result was already partially consumed, disabling buffering does nothing and Result will still use the buffer. diff --git a/src/Drivers/Mysqli/MysqliEmptyResultAdapter.php b/src/Drivers/Mysqli/MysqliEmptyResultAdapter.php index ef869c1d..bfcdd7dc 100644 --- a/src/Drivers/Mysqli/MysqliEmptyResultAdapter.php +++ b/src/Drivers/Mysqli/MysqliEmptyResultAdapter.php @@ -13,6 +13,18 @@ class MysqliEmptyResultAdapter implements IResultAdapter use StrictObjectTrait; + public function toBuffered(): IResultAdapter + { + return $this; + } + + + public function toUnbuffered(): IResultAdapter + { + return $this; + } + + public function seek(int $index): void { throw new InvalidArgumentException("Unable to seek in row set to {$index} index."); diff --git a/src/Drivers/Mysqli/MysqliResultAdapter.php b/src/Drivers/Mysqli/MysqliResultAdapter.php index 966db742..b19eee72 100644 --- a/src/Drivers/Mysqli/MysqliResultAdapter.php +++ b/src/Drivers/Mysqli/MysqliResultAdapter.php @@ -63,6 +63,18 @@ public function __destruct() } + public function toBuffered(): IResultAdapter + { + return $this; + } + + + public function toUnbuffered(): IResultAdapter + { + return $this; + } + + public function seek(int $index): void { if ($this->result->num_rows !== 0 && !$this->result->data_seek($index)) { diff --git a/src/Drivers/PdoMysql/PdoMysqlDriver.php b/src/Drivers/PdoMysql/PdoMysqlDriver.php index 3d7279e4..a452f559 100644 --- a/src/Drivers/PdoMysql/PdoMysqlDriver.php +++ b/src/Drivers/PdoMysql/PdoMysqlDriver.php @@ -135,7 +135,7 @@ public function convertToPhp($value, $nativeType) protected function createResultAdapter(PDOStatement $statement): IResultAdapter { - return new PdoMysqlResultAdapter($statement); + return (new PdoMysqlResultAdapter($statement))->toBuffered(); } diff --git a/src/Drivers/PdoMysql/PdoMysqlResultAdapter.php b/src/Drivers/PdoMysql/PdoMysqlResultAdapter.php index ca2947b2..c8cb29a6 100644 --- a/src/Drivers/PdoMysql/PdoMysqlResultAdapter.php +++ b/src/Drivers/PdoMysql/PdoMysqlResultAdapter.php @@ -5,6 +5,7 @@ use Nextras\Dbal\Exception\InvalidStateException; use Nextras\Dbal\Exception\NotSupportedException; +use Nextras\Dbal\Result\BufferedResultAdapter; use Nextras\Dbal\Result\IResultAdapter; use Nextras\Dbal\Utils\StrictObjectTrait; use PDO; @@ -58,10 +59,25 @@ public function __construct(PDOStatement $statement) } + public function toBuffered(): IResultAdapter + { + return new BufferedResultAdapter($this); + } + + + public function toUnbuffered(): IResultAdapter + { + return $this; + } + + public function seek(int $index): void { - if ($index === 0 && $this->beforeFirstFetch) return; - throw new NotSupportedException("PDO does not support seek & replay. Use Result::fetchAll() to and result its result."); + if ($index === 0 && $this->beforeFirstFetch) { + return; + } + + throw new NotSupportedException("PDO does not support rewinding or seeking. Use Result::buffered() before first consume of the result."); } diff --git a/src/Drivers/PdoPgsql/PdoPgsqlDriver.php b/src/Drivers/PdoPgsql/PdoPgsqlDriver.php index 4b64a0a9..a040a35a 100644 --- a/src/Drivers/PdoPgsql/PdoPgsqlDriver.php +++ b/src/Drivers/PdoPgsql/PdoPgsqlDriver.php @@ -110,7 +110,7 @@ public function convertToPhp($value, $nativeType) protected function createResultAdapter(PDOStatement $statement): IResultAdapter { - return new PdoPgsqlResultAdapter($statement); + return (new PdoPgsqlResultAdapter($statement))->toBuffered(); } diff --git a/src/Drivers/PdoPgsql/PdoPgsqlResultAdapter.php b/src/Drivers/PdoPgsql/PdoPgsqlResultAdapter.php index a5ac0b81..a4def2b9 100644 --- a/src/Drivers/PdoPgsql/PdoPgsqlResultAdapter.php +++ b/src/Drivers/PdoPgsql/PdoPgsqlResultAdapter.php @@ -5,6 +5,7 @@ use Nextras\Dbal\Exception\InvalidStateException; use Nextras\Dbal\Exception\NotSupportedException; +use Nextras\Dbal\Result\BufferedResultAdapter; use Nextras\Dbal\Result\IResultAdapter; use Nextras\Dbal\Utils\StrictObjectTrait; use PDO; @@ -54,10 +55,25 @@ public function __construct(PDOStatement $statement) } + public function toBuffered(): IResultAdapter + { + return new BufferedResultAdapter($this); + } + + + public function toUnbuffered(): IResultAdapter + { + return $this; + } + + public function seek(int $index): void { - if ($index === 0 && $this->beforeFirstFetch) return; - throw new NotSupportedException("PDO does not support seek & replay. Use Result::fetchAll() to and result its result."); + if ($index === 0 && $this->beforeFirstFetch) { + return; + } + + throw new NotSupportedException("PDO does not support rewinding or seeking. Use Result::buffered() before first consume of the result."); } diff --git a/src/Drivers/Pgsql/PgsqlResultAdapter.php b/src/Drivers/Pgsql/PgsqlResultAdapter.php index b958c09c..0dc4df1c 100644 --- a/src/Drivers/Pgsql/PgsqlResultAdapter.php +++ b/src/Drivers/Pgsql/PgsqlResultAdapter.php @@ -70,6 +70,18 @@ public function __destruct() } + public function toBuffered(): IResultAdapter + { + return $this; + } + + + public function toUnbuffered(): IResultAdapter + { + return $this; + } + + public function seek(int $index): void { if (pg_num_rows($this->result) !== 0 && !pg_result_seek($this->result, $index)) { diff --git a/src/Drivers/Sqlsrv/SqlsrvResultAdapter.php b/src/Drivers/Sqlsrv/SqlsrvResultAdapter.php index 97a67369..e1ac68ef 100644 --- a/src/Drivers/Sqlsrv/SqlsrvResultAdapter.php +++ b/src/Drivers/Sqlsrv/SqlsrvResultAdapter.php @@ -55,6 +55,18 @@ public function __destruct() } + public function toBuffered(): IResultAdapter + { + return $this; + } + + + public function toUnbuffered(): IResultAdapter + { + return $this; + } + + public function seek(int $index): void { if ($index !== 0 && sqlsrv_num_rows($this->statement) !== 0 && sqlsrv_fetch($this->statement, SQLSRV_SCROLL_ABSOLUTE, $index) !== true) { diff --git a/src/Result/BufferedResultAdapter.php b/src/Result/BufferedResultAdapter.php new file mode 100644 index 00000000..a82aa53f --- /dev/null +++ b/src/Result/BufferedResultAdapter.php @@ -0,0 +1,101 @@ +|null */ + private $data; + + + public function __construct(IResultAdapter $adapter) + { + $this->adapter = $adapter; + } + + + public function toBuffered(): IResultAdapter + { + return $this; + } + + + public function toUnbuffered(): IResultAdapter + { + if ($this->data === null) { + return $this->adapter->toUnbuffered(); + } else { + return $this; + } + } + + + public function seek(int $index): void + { + if ($this->data === null) { + $this->init(); + } + assert($this->data !== null); + + if ($index === 0) { + $this->data->rewind(); + return; + } + + try { + $this->data->seek($index); + } catch (OutOfBoundsException $e) { + throw new InvalidArgumentException("Unable to seek in row set to {$index} index.", 0, $e); + } + } + + + public function fetch(): ?array + { + if ($this->data === null) { + $this->init(); + } + assert($this->data !== null); + + $fetched = $this->data->valid() ? $this->data->current() : null; + $this->data->next(); + return $fetched; + } + + + public function getTypes(): array + { + return $this->adapter->getTypes(); + } + + + public function getRowsCount(): int + { + if ($this->data === null) { + $this->init(); + } + assert($this->data !== null); + + return $this->data->count(); + } + + + private function init(): void + { + $rows = []; + while (($row = $this->adapter->fetch()) !== null) { + $rows[] = $row; + } + $this->data = new ArrayIterator($rows); + } +} diff --git a/src/Result/IResultAdapter.php b/src/Result/IResultAdapter.php index 91564b90..05367cfe 100644 --- a/src/Result/IResultAdapter.php +++ b/src/Result/IResultAdapter.php @@ -17,6 +17,21 @@ interface IResultAdapter public const TYPE_AS_IS = 64; + /** + * Converts result adapter to buffered version. + * @internal + */ + public function toBuffered(): IResultAdapter; + + + /** + * Converts result adapter to not explicitly buffered version. + * The resulting adapter may be naturally buffered by PHP's extension implementation. + * @internal + */ + public function toUnbuffered(): IResultAdapter; + + /** * @throws InvalidArgumentException */ diff --git a/src/Result/Result.php b/src/Result/Result.php index 01054c73..a6ed838a 100644 --- a/src/Result/Result.php +++ b/src/Result/Result.php @@ -71,6 +71,30 @@ public function __construct(IResultAdapter $adapter, IDriver $driver) } + /** + * Enables emulated buffering mode to allow rewinding the result multiple times or seeking to specific position. + * This will enable emulated buffering for drivers that do not support buffering & scrolling the result. + * @return static + */ + public function buffered(): Result + { + $this->adapter = $this->adapter->toBuffered(); + return $this; + } + + + /** + * Disables emulated buffering mode. + * Emulated buffering may not be disabled when the result was already (partially) consumed. + * @return static + */ + public function unbuffered(): Result + { + $this->adapter = $this->adapter->toUnbuffered(); + return $this; + } + + public function getAdapter(): IResultAdapter { return $this->adapter; @@ -163,7 +187,9 @@ public function fetchPairs(?string $key = null, ?string $value = null): array public function getColumns(): array { return array_map( - function($name): string { return (string) $name; }, // @phpstan-ignore-line + function ($name): string { + return (string) $name; // @phpstan-ignore-line + }, array_keys($this->adapter->getTypes()) ); } diff --git a/tests/cases/integration/result.buffering.phpt b/tests/cases/integration/result.buffering.phpt new file mode 100644 index 00000000..21ae2323 --- /dev/null +++ b/tests/cases/integration/result.buffering.phpt @@ -0,0 +1,51 @@ +connection->getDriver() instanceof PdoDriver) { + Environment::skip('Explicit buffering is needed only in PDO drivers.'); + } + + $this->initData($this->connection); + $this->lockConnection($this->connection); + + $buffered = $this->connection->query('SELECT * FROM books ORDER BY id')->buffered(); + Assert::same([1, 2, 3, 4], $buffered->fetchPairs(null, 'id')); + Assert::same([1, 2, 3, 4], $buffered->fetchPairs(null, 'id')); // repeated + Assert::same(4, $buffered->count()); + + $unbuffered = $this->connection->query('SELECT * FROM books ORDER BY id')->buffered()->unbuffered(); + Assert::same([1, 2, 3, 4], $unbuffered->fetchPairs(null, 'id')); + Assert::throws(function () use ($unbuffered): void { + $unbuffered->fetchPairs(null, 'id'); + }, NotSupportedException::class); + + $lateChanged = $this->connection->query('SELECT * FROM books ORDER BY id')->buffered(); + Assert::same([1, 2, 3, 4], $buffered->fetchPairs(null, 'id')); + $lateChanged->unbuffered(); + Assert::same([1, 2, 3, 4], $buffered->fetchPairs(null, 'id')); + } +} + + +$test = new ResultBufferingIntegrationTest(); +$test->run(); diff --git a/tests/cases/integration/result.phpt b/tests/cases/integration/result.phpt index 160fbcc3..86938b67 100644 --- a/tests/cases/integration/result.phpt +++ b/tests/cases/integration/result.phpt @@ -8,10 +8,8 @@ namespace NextrasTests\Dbal; -use Nextras\Dbal\Drivers\Pdo\PdoDriver; use Nextras\Dbal\Drivers\PdoPgsql\PdoPgsqlDriver; use Nextras\Dbal\Exception\InvalidArgumentException; -use Nextras\Dbal\Exception\NotSupportedException; use Nextras\Dbal\Platforms\SqlServerPlatform; use Nextras\Dbal\Utils\DateTimeImmutable; use Tester\Assert; @@ -70,13 +68,6 @@ class ResultIntegrationTest extends IntegrationTestCase $books = $result->fetchPairs(null, 'id'); Assert::same([1, 2, 3, 4], $books); - if ($this->connection->getDriver() instanceof PdoDriver) { - Assert::throws(function () use ($result): void { - $result->fetchPairs(null, 'id'); - }, NotSupportedException::class); - return; - } - $books = $result->fetchPairs(null, 'id'); Assert::same([1, 2, 3, 4], $books);