Skip to content

Commit

Permalink
sql array to php conversion
Browse files Browse the repository at this point in the history
  • Loading branch information
pounard committed Feb 27, 2024
1 parent 01d9db1 commit 247f9bf
Show file tree
Hide file tree
Showing 5 changed files with 499 additions and 2 deletions.
15 changes: 13 additions & 2 deletions src/Converter/Converter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace MakinaCorpus\QueryBuilder\Converter;

use MakinaCorpus\QueryBuilder\Converter\Helper\ArrayRowParser;
use MakinaCorpus\QueryBuilder\Error\ValueConversionError;
use MakinaCorpus\QueryBuilder\Expression;
use MakinaCorpus\QueryBuilder\ExpressionFactory;
Expand Down Expand Up @@ -132,8 +133,7 @@ public function fromSql(string $type, int|float|string $value): mixed
// }

if (\str_ends_with($type, '[]')) {
// @todo Handle collections.
throw new ValueConversionError("Handling arrays is not implemented yet.");
return $this->parseArrayRecursion(\substr($type, 0, -2), ArrayRowParser::parseArray($value));
}

try {
Expand Down Expand Up @@ -218,6 +218,17 @@ public function toSql(mixed $value, ?string $type = null): null|int|float|string
return $this->toSqlDefault($type, $value);
}

/**
* Parse SQL ARRAY output recursively.
*/
protected function parseArrayRecursion(?string $type, array $values): array
{
return \array_map(
fn (mixed $value) => \is_array($value) ? $this->parseArrayRecursion($type, $value) : $this->fromSql($type, $value),
$values,
);
}

/**
* Allow bridge specific implementations to create their own context.
*/
Expand Down
206 changes: 206 additions & 0 deletions src/Converter/Helper/ArrayRowParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<?php

declare(strict_types=1);

namespace MakinaCorpus\QueryBuilder\Converter\Helper;

use MakinaCorpus\QueryBuilder\Error\QueryBuilderError;
use MakinaCorpus\QueryBuilder\Error\ValueConversionError;

/**
* In standard SQL, array are types, you can't have an array with different
* types within, in opposition to rows, which can have different values.
*
* This is very sensitive code, any typo will breaks tons of stuff, please
* always run unit tests, everytime you modify anything in here, really.
*
* And yes, sadly, it is very slow. This code could heavily benefit from being
* pluggable and use some FFI plugged library developed using any other faster
* language/techno.
*/
final class ArrayRowParser
{
/**
* Parse a pgsql array return string.
*
* @return string[]
* Raw SQL values.
*/
public static function isArray(string $string): bool

Check failure on line 29 in src/Converter/Helper/ArrayRowParser.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

PHPDoc tag @return with type array<string> is incompatible with native type bool.
{
return ($length = \strlen($string)) >= 2 && '{' === $string[0] && '}' === $string[$length - 1];
}

/**
* Parse a pgsql array return string.
*
* @return string[]
* Raw SQL values.
*/
public static function parseArray(string $string): array
{
$string = \trim($string);
$length = \strlen($string);

if (0 === $length) { // Empty string
return [];
}
if ($length < 2) {
throw new QueryBuilderError("malformed input: string length must be 0 or at least 2");
}
if ('{' !== $string[0] || '}' !== $string[$length - 1]) {
throw new QueryBuilderError("malformed input: array must be enclosed using {}");
}

return self::parseRecursion($string, 1, $length, '}')[0];
}

/**
* Parse a row.
*/
public static function parseRow(string $string): array
{
$length = \strlen($string);

if (0 === $length) { // Empty string
return [];
}
if ($length < 2) {
throw new QueryBuilderError("malformed input: string length must be 0 or at least 2");
}
if ('(' !== $string[0] || ')' !== $string[$length - 1]) {
throw new QueryBuilderError("malformed input: row must be enclosed using ()");
}

return self::parseRecursion($string, 1, $length, ')')[0];
}

/**
* Write a row.
*
* Each value can have a different type, this is supposed to be called when
* the query builder builds the query, it's the value formatter job to know
* what to do which each value, which could be itself an Expression object,
* hence the need for a serializer callback here.
*/
public static function writeRow(array $row, callable $serializeItem): string
{
return '{'.\implode(',', \array_map($serializeItem, $row)).'}';
}

/**
* Write array.
*
* Every value is supposed to have the same type, but this is supposed to
* be at the discretion of who's asking for writing this array, usually the
* SQL formatter, hence the need for a serializer callback here.
*/
public static function writeArray(array $data, callable $serializeItem): string
{
$values = [];

foreach ($data as $value) {
if (\is_array($value)) {
$values[] = self::writeArray($value, $serializeItem);
} else {
$values[] = \call_user_func($serializeItem, $value);
}
}

return '{'.\implode(',', $values).'}';
}

/**
* Unescape a SQL string.
*
* @todo I think it is wrong, but up until now, it seem to work.
*/
private static function escapeString(string $value): string
{
return "'".\str_replace('\\', '\\\\', $value)."'";
}

/**
* From a quoted string, find the end of it and return it.
*/
private static function findUnquotedStringEnd(string $string, int $start, int $length, string $endChar): int
{
for ($i = $start; $i < $length; $i++) {
$char = $string[$i];
if (',' === $char || $endChar === $char) {
return $i - 1;
}
}
throw new ValueConversionError("malformed input: unterminated unquoted string starting at ".$start);
}

/**
* From a unquioted string, find the end of it and return it.
*/
private static function findQuotedStringEnd(string $string, int $start, int $length): int
{
for ($i = $start; $i < $length; $i++) {
$char = $string[$i];
if ('\\' === $char) {
if ($i === $length) {
throw new QueryBuilderError(\sprintf("misplaced \\ escape char at end of string"));
}
$string[$i++]; // Skip escaped char
} else if ('"' === $char) {
return $i;
}
}
throw new ValueConversionError("malformed input: unterminated quoted string starting at ".$start);
}

/**
* Unescape escaped user string from SQL.
*/
private static function unescapeString(string $string): string
{
return \str_replace('\\\\', '\\', \str_replace('\\"', '"', $string));
}

/**
* Parse any coma-separated values string from SQL.
*/
private static function parseRecursion(string $string, int $start, int $length, string $endChar): array
{
$ret = [];

for ($i = $start; $i < $length; ++$i) {
$char = $string[$i];
if (',' === $char) {
// Next string
} else if ('(' === $char) { // Row.
list($child, $stop) = self::parseRecursion($string, $i + 1, $length, ')');
$ret[] = $child;
$i = $stop;
} else if ('{' === $char) { // Array.
list($child, $stop) = self::parseRecursion($string, $i + 1, $length, '}');
$ret[] = $child;
$i = $stop;
} else if ($endChar === $char) { // End of recursion.
return [$ret, $i + 1];
} else if ('}' === $char) {
throw new QueryBuilderError(\sprintf("malformed input: unexpected end of array '}' at position %d", $i));
} else if (')' === $char) {
throw new QueryBuilderError(\sprintf("malformed input: unexpected end of row ')' at position %d", $i));
} else if (',' === $char) {
throw new QueryBuilderError(\sprintf("malformed input: unexpected separator ',' at position %d", $i));
} else {
if ('"' === $char) {
$i++; // Skip start quote
$stop = self::findQuotedStringEnd($string, $i, $length);
$ret[] = self::unescapeString(\substr($string, $i, $stop - $i));
} else {
$stop = self::findUnquotedStringEnd($string, $i, $length, $endChar);
$ret[] = \substr($string, $i, $stop - $i + 1);
}
$i = $stop;
}
}

return [$ret, $length];
}
}
92 changes: 92 additions & 0 deletions src/Converter/Helper/PgSQLArrayConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace Goat\Converter\Driver;

use Goat\Converter\ConverterContext;
use Goat\Converter\DynamicInputValueConverter;
use Goat\Converter\DynamicOutputValueConverter;
use Goat\Converter\TypeConversionError;

/**
* PostgreSQL array converter, compatible with SQL standard.
*/
class PgSQLArrayConverter implements DynamicInputValueConverter, DynamicOutputValueConverter

Check failure on line 15 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Class Goat\Converter\Driver\PgSQLArrayConverter implements unknown interface Goat\Converter\DynamicInputValueConverter.

Check failure on line 15 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Class Goat\Converter\Driver\PgSQLArrayConverter implements unknown interface Goat\Converter\DynamicOutputValueConverter.
{
/**
* {@inheritdoc}
*/
public function supportsOutput(?string $phpType, ?string $sqlType, string $value): bool
{
return 'array' === $phpType || ($sqlType && (\str_ends_with($sqlType, '[]') || \str_starts_with($sqlType, '_')));
}

/**
* {@inheritdoc}
*/
public function fromSQL(string $phpType, ?string $sqlType, string $value, ConverterContext $context)

Check failure on line 28 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Parameter $context of method Goat\Converter\Driver\PgSQLArrayConverter::fromSQL() has invalid type Goat\Converter\ConverterContext.
{
if ('' === $value || '{}' === $value) {
return [];
}

return $this->recursiveFromSQL($this->findSubtype($sqlType) ?? 'varchar', PgSQLParser::parseArray($value), $context);

Check failure on line 34 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Call to static method parseArray() on an unknown class Goat\Converter\Driver\PgSQLParser.
}

/**
* {@inheritdoc}
*/
public function supportsInput(string $sqlType, mixed $value): bool
{
return \is_array($value) && (\str_ends_with($sqlType, '[]') || \str_starts_with($sqlType, '_') || \str_starts_with($value, '{'));
}

/**
* {@inheritdoc}
*/
public function toSQL(string $sqlType, mixed $value, ConverterContext $context): ?string

Check failure on line 48 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Parameter $context of method Goat\Converter\Driver\PgSQLArrayConverter::toSQL() has invalid type Goat\Converter\ConverterContext.
{
if (!\is_array($value)) {
throw new TypeConversionError("Value must be an array.");

Check failure on line 51 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Instantiated class Goat\Converter\TypeConversionError not found.

Check failure on line 51 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Throwing object of an unknown class Goat\Converter\TypeConversionError.
}
if (empty($value)) {
return '{}';
}

$converter = $context->getConverter();

Check failure on line 57 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Call to method getConverter() on an unknown class Goat\Converter\ConverterContext.
$subType = $this->findSubtype($sqlType);

return PgSQLParser::writeArray(

Check failure on line 60 in src/Converter/Helper/PgSQLArrayConverter.php

View workflow job for this annotation

GitHub Actions / Static Analysis (8.2)

Call to static method writeArray() on an unknown class Goat\Converter\Driver\PgSQLParser.
$value,
fn ($value) => $converter->toSQL($value, $subType),
);
}

private function recursiveFromSQL(?string $sqlType, array $values, ConverterContext $context): array
{
$converter = $context->getConverter();

return \array_map(
fn ($value) => (\is_array($value) ?
$this->recursiveFromSQL($sqlType, $value, $context) :
// @todo Is there any way to be deterministic with PHP value type?
$converter->fromSQL($value, $sqlType, null)
),
$values
);
}

private function findSubtype(?string $type): ?string
{
if ($type) {
if (\str_ends_with($type, '[]')) {
return \substr($type, 0, -2);
}
if (\str_starts_with($type, '_')) {
return \substr($type, 1);
}
}
return null;
}
}
41 changes: 41 additions & 0 deletions tests/Converter/ConverterUnitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,32 @@ public function testFromSqlPlugin(): void
self::assertInstanceof(\DateTimeImmutable::class, $converter->fromSql(\DateTimeImmutable::class, "2012-12-12 12:12:12"));
}

public function testFromSqlArrayEmpty(): void
{
$converter = new Converter();

self::assertSame([], $converter->fromSql('int[]', "{}"));
}

public function testFromSqlArray(): void
{
$converter = new Converter();

self::assertSame([1, 7, 11], $converter->fromSql('int[]', "{1,7,11}"));
}

public function testFromSqlArrayRecursive(): void
{
$converter = new Converter();

self::assertSame([[1, 2], [3, 4]], $converter->fromSql('int[]', "{{1,2},{3,4}}"));
}

public function testFromSqlArrayRows(): void
{
self::markTestIncomplete("Not implemented yet.");
}

public function testToSqlInt(): void
{
self::assertSame(
Expand Down Expand Up @@ -207,4 +233,19 @@ public function testToSqlGuessTypeUnknownFallback(): void
(new Converter())->toSql($object),
);
}

public function testToSqlArray(): void
{
self::markTestIncomplete("Not implemented yet.");
}

public function testToSqlArrayRecursive(): void
{
self::markTestIncomplete("Not implemented yet.");
}

public function testToSqlArrayRows(): void
{
self::markTestIncomplete("Not implemented yet.");
}
}
Loading

0 comments on commit 247f9bf

Please sign in to comment.