-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
499 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 GitHub Actions / Static Analysis (8.2)
|
||
{ | ||
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]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 GitHub Actions / Static Analysis (8.2)
|
||
{ | ||
/** | ||
* {@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) | ||
{ | ||
if ('' === $value || '{}' === $value) { | ||
return []; | ||
} | ||
|
||
return $this->recursiveFromSQL($this->findSubtype($sqlType) ?? 'varchar', PgSQLParser::parseArray($value), $context); | ||
} | ||
|
||
/** | ||
* {@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 | ||
{ | ||
if (!\is_array($value)) { | ||
throw new TypeConversionError("Value must be an array."); | ||
Check failure on line 51 in src/Converter/Helper/PgSQLArrayConverter.php GitHub Actions / Static Analysis (8.2)
|
||
} | ||
if (empty($value)) { | ||
return '{}'; | ||
} | ||
|
||
$converter = $context->getConverter(); | ||
$subType = $this->findSubtype($sqlType); | ||
|
||
return PgSQLParser::writeArray( | ||
$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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.