Skip to content

Commit

Permalink
Parse and check dimensions in array string (see #12)
Browse files Browse the repository at this point in the history
  • Loading branch information
sad-spirit committed Jun 18, 2024
1 parent 0e5436e commit b32a1cf
Show file tree
Hide file tree
Showing 3 changed files with 219 additions and 9 deletions.
16 changes: 14 additions & 2 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [Unreleased]

### Fixed
* String representations of arrays which include dimensions (e.g. `[0:0]={1}`) are now supported
(see issue #12). An exception will be thrown if the structure of the resultant array does not match
specified dimensions. If a lower bound is specified for a dimension, the keys in resultant PHP array
will start with that: `'[5:5]={1}'` will be converted to `[5 => 1]`.
* Arrays of elements that have a delimiter other than comma are supported. In particular,
converting from / to `box[]` type, which uses semicolon, is possible. Converters for custom
types having a non-comma delimiter may implement the new `CustomArrayDelimiter` interface.

## [2.4.0] - 2024-05-20

A major update for `PreparedStatement`'s API, multiple renames.
Expand Down Expand Up @@ -267,5 +278,6 @@ Initial release on GitHub
[2.1.1]: https://github.com/sad-spirit/pg-wrapper/compare/v2.1.0...v2.1.1
[2.2.0]: https://github.com/sad-spirit/pg-wrapper/compare/v2.1.1...v2.2.0
[2.3.0-beta]: https://github.com/sad-spirit/pg-wrapper/compare/v2.2.0...v2.3.0-beta
[2.3.0]: https://github.com/sad-spirit/pg-wrapper/compare/v2.3.0-beta..v2.3.0
[2.4.0]: https://github.com/sad-spirit/pg-wrapper/compare/v2.3.0..v2.4.0
[2.3.0]: https://github.com/sad-spirit/pg-wrapper/compare/v2.3.0-beta...v2.3.0
[2.4.0]: https://github.com/sad-spirit/pg-wrapper/compare/v2.3.0...v2.4.0
[Unreleased]: https://github.com/sad-spirit/pg-wrapper/compare/v2.4.0...HEAD
107 changes: 100 additions & 7 deletions src/sad_spirit/pg_wrapper/converters/containers/ArrayConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,15 +206,101 @@ private function calculateRequiredSizes(array $value): array
return $sizes;
}

/**
* {@inheritDoc}
*
* This is a non-recursive part of array literal parsing, it handles the possible array dimensions that can only
* appear at the very beginning.
*/
protected function parseInput(string $native, int &$pos): array
{
if ('[' === $this->nextChar($native, $pos)) {
$dimensions = $this->parseDimensions($native, $pos);
}
return $this->parseArrayRecursive($native, $pos, $dimensions ?? null);
}

/**
* Parses the array dimensions specification
*
* @param string $native
* @param int $pos
* @return list<array{int,int}> Contains the first key and number of elements for each dimension
*/
private function parseDimensions(string $native, int &$pos): array
{
$dimensions = [];

do {
// Postgres does not allow whitespace inside dimension specifications, neither should we
if (!\preg_match('/\[([+-]?\d+)(?::([+-]?\d+))?/As', $native, $m, 0, $pos)) {
throw TypeConversionException::parsingFailed(
$this,
"array bounds after '['",
$native,
$pos + 1
);
}
if (!isset($m[2])) {
$lower = 1;
$upper = (int)$m[1];
} else {
$lower = (int)$m[1];
$upper = (int)$m[2];
}
if ($lower > $upper) {
throw new TypeConversionException(\sprintf(
'Array upper bound (%d) cannot be less than lower bound (%d)',
$lower,
$upper
));
}

// Convert to standard PHP 0-based array unless lower bound was given
if (!isset($m[2])) {
$dimensions[] = [0, $upper];
} else {
$dimensions[] = [$lower, $upper - $lower + 1];
}

$pos += \strlen($m[0]);
$this->expectChar($native, $pos, ']');
} while ('[' === ($char = $this->nextChar($native, $pos)));

if ('=' !== $char) {
throw TypeConversionException::parsingFailed($this, "'=' after array dimensions", $native, $pos);
}
$pos++;

return $dimensions;
}

/**
* Recursively parses the string representation of an array
*
* @param string $native
* @param int $pos
* @param list<array{int,int}>|null $dimensions Will be not null if the literal contained dimensions
* @return array
*/
private function parseArrayRecursive(string $native, int &$pos, ?array $dimensions = null): array
{
$result = [];

if (null === $dimensions) {
$key = 0;
$count = null;
} elseif ([] !== $dimensions) {
[$key, $count] = \array_shift($dimensions);
} else {
throw new TypeConversionException("Specified array dimensions do not match array contents");
}

$this->expectChar($native, $pos, '{'); // Leading "{".

while ('}' !== ($char = $this->nextChar($native, $pos))) {
// require a delimiter between elements
if (!empty($result)) {
if ([] !== $result) {
if ($this->delimiter !== $char) {
throw TypeConversionException::parsingFailed($this, "'{$this->delimiter}'", $native, $pos);
}
Expand All @@ -224,15 +310,18 @@ protected function parseInput(string $native, int &$pos): array

if ('{' === $char) {
// parse sub-array
$result[] = $this->parseInput($native, $pos);
$result[$key++] = $this->parseArrayRecursive($native, $pos, $dimensions);

} elseif (null !== $dimensions && [] !== $dimensions) {
throw new TypeConversionException("Specified array dimensions do not match array contents");

} elseif ('"' === $char) {
// quoted string
if (!preg_match('/"((?>[^"\\\\]+|\\\\.)*)"/As', $native, $m, 0, $pos)) {
throw TypeConversionException::parsingFailed($this, 'quoted string', $native, $pos);
}
$result[] = $this->itemConverter->input(stripcslashes($m[1]));
$pos += strlen($m[0]);
$result[$key++] = $this->itemConverter->input(stripcslashes($m[1]));
$pos += strlen($m[0]);

} else {
// zero-length string can appear only quoted
Expand All @@ -244,13 +333,17 @@ protected function parseInput(string $native, int &$pos): array
$pos
);
}
$v = substr($native, $pos, $len);
$result[] = strcasecmp($v, "null") ? $this->itemConverter->input(stripcslashes($v)) : null;
$pos += $len;
$v = substr($native, $pos, $len);
$result[$key++] = strcasecmp($v, "null") ? $this->itemConverter->input(stripcslashes($v)) : null;
$pos += $len;
}
}
$pos++; // skip trailing "}"

if (null !== $count && \count($result) !== $count) {
throw new TypeConversionException("Specified array dimensions do not match array contents");
}

return $result;
}
}
105 changes: 105 additions & 0 deletions tests/converters/ArrayDimensionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

/**
* Converter of complex PostgreSQL types and an OO wrapper for PHP's pgsql extension
*
* LICENSE
*
* This source file is subject to BSD 2-Clause License that is bundled
* with this package in the file LICENSE and available at the URL
* https://raw.githubusercontent.com/sad-spirit/pg-wrapper/master/LICENSE
*
* @package sad_spirit\pg_wrapper
* @copyright 2014-2024 Alexey Borzov
* @author Alexey Borzov <avb@php.net>
* @license https://opensource.org/licenses/BSD-2-Clause BSD 2-Clause license
* @link https://github.com/sad-spirit/pg-wrapper
*/

declare(strict_types=1);

namespace sad_spirit\pg_wrapper\tests\converters;

use PHPUnit\Framework\TestCase;
use sad_spirit\pg_wrapper\converters\containers\ArrayConverter;
use sad_spirit\pg_wrapper\converters\IntegerConverter;
use sad_spirit\pg_wrapper\exceptions\TypeConversionException;

/**
* Postgres may return an array literal with dimensions specified
*
* @link https://github.com/sad-spirit/pg-wrapper/issues/12
*/
class ArrayDimensionsTest extends TestCase
{
/** @var ArrayConverter */
private $converter;

protected function setUp(): void
{
$this->converter = new ArrayConverter(new IntegerConverter());
}

/**
* @dataProvider invalidDimensions
*/
public function testInvalidDimensionsSpecifications(string $invalid, string $message): void
{
$this::expectException(TypeConversionException::class);
$this::expectExceptionMessage($message);

$this->converter->input($invalid);
}

/**
* @dataProvider mismatchedDimensions
*/
public function testDimensionsMismatch(string $invalid): void
{
$this::expectException(TypeConversionException::class);
$this::expectExceptionMessage('do not match');

$this->converter->input($invalid);
}

/**
* @dataProvider validDimensions
*/
public function testValidDimensions(string $input, array $expected): void
{
$this::assertEquals($expected, $this->converter->input($input));
}

public function invalidDimensions(): array
{
return [
['[1', "expecting ']'"],
['[1]{1}', "expecting '='"],
['[ 1 ]={1}', "expecting array bounds after '['"],
['[2:1]={1,2}', "cannot be less"],
['[-1]={1}', "cannot be less"]
];
}

public function mismatchedDimensions(): array
{
return [
['[1]={{2}}'],
['[1][1]={2}'],
['[1]={2,3}'],
['[2:3]={4,5,6}'],
['[2][2]={{1},{1}}']
];
}

public function validDimensions(): array
{
return [
['[1]={2}', [2]],
['[-1:-1]={2}', [-1 => 2]],
['[0:1]={1,2}', [1, 2]],
['[2][2]={{1,2},{3,4}}', [[1, 2], [3, 4]]],
['[1:2] [3:4] = {{1,2},{3,4}}', [1 => [3 => 1, 4 => 2], 2 => [3 => 3, 4 => 4]]]
];
}
}

0 comments on commit b32a1cf

Please sign in to comment.