Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ composer.phar
# phpunit itself is not needed
phpunit.phar
phpunit.result.cache
.phpunit.result.cache

# local phpunit config
/phpunit.xml
Expand Down
33 changes: 29 additions & 4 deletions src/ArrayParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

namespace Yiisoft\Db\Pgsql;

use JsonException;
use function in_array;
use function json_decode;
use function strlen;
use const JSON_THROW_ON_ERROR;

/**
* The class converts PostgresSQL array representation to PHP array.
Expand All @@ -17,6 +20,11 @@ final class ArrayParser
*/
private string $delimiter = ',';

/**
* @var string|null cast array values to php type
*/
private ?string $typeCast = null;

/**
* Convert array from PostgresSQL to PHP
*
Expand All @@ -37,13 +45,22 @@ public function parse(?string $value): ?array
return $this->parseArray($value);
}

public function withTypeCast(?string $typeCast): self
{
$new = clone $this;
$new->typeCast = $typeCast;

return $new;
}

/**
* Pares PgSQL array encoded in string.
*
* @param string $value
* @param int $i parse starting position.
*
* @return array
* @throws JsonException
*/
private function parseArray(string $value, int &$i = 0): array
{
Expand All @@ -69,6 +86,7 @@ private function parseArray(string $value, int &$i = 0): array
}
break;
default:
/** @var mixed */
$result[] = $this->parseString($value, $i);
}
}
Expand All @@ -82,9 +100,10 @@ private function parseArray(string $value, int &$i = 0): array
* @param string $value
* @param int $i parse starting position.
*
* @return string|null
* @return mixed
* @throws JsonException
*/
private function parseString(string $value, int &$i): ?string
private function parseString(string $value, int &$i): mixed
{
$isQuoted = $value[$i] === '"';
$stringEndChars = $isQuoted ? ['"'] : [$this->delimiter, '}'];
Expand All @@ -104,9 +123,15 @@ private function parseString(string $value, int &$i): ?string
$i -= $isQuoted ? 0 : 1;

if (!$isQuoted && $result === 'NULL') {
$result = null;
return null;
}

return $result;
return match ($this->typeCast) {
Schema::PHP_TYPE_INTEGER => (int) $result,
Schema::PHP_TYPE_DOUBLE => (float) $result,
Schema::PHP_TYPE_ARRAY => json_decode($result, true, 512, JSON_THROW_ON_ERROR),
Schema::PHP_TYPE_BOOLEAN => ColumnSchema::castBooleanValue($result),
default => $result,
};
}
}
83 changes: 57 additions & 26 deletions src/ColumnSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Schema\ColumnSchema as AbstractColumnSchema;
use Yiisoft\Db\Schema\Schema as AbstractSchema;

use function array_walk_recursive;
use function in_array;
use function is_array;
use function is_bool;
use function is_float;
use function is_string;
use function json_decode;
use function strtolower;
use const JSON_THROW_ON_ERROR;

/**
* The class ColumnSchema for PostgreSQL database.
Expand All @@ -33,6 +33,21 @@ final class ColumnSchema extends AbstractColumnSchema
*/
private ?string $sequenceName = null;

/**
* Return type of PgSql array values
*
* @return string|null
*/
public function getPhpArrayType(): ?string
{
return $this->dimension > 0 ? parent::getPhpType() : null;
}

public function getPhpType(): ?string
{
return $this->dimension > 0 ? AbstractSchema::PHP_TYPE_ARRAY : parent::getPhpType();
}

/**
* Converts the input value according to {@see type} and {@see dbType} for use in a db query.
*
Expand Down Expand Up @@ -78,17 +93,12 @@ public function dbTypecast(mixed $value): mixed
public function phpTypecast(mixed $value): mixed
{
if ($this->dimension > 0) {
if (!is_array($value) && (is_string($value) || $value === null)) {
$value = $this->getArrayParser()->parse($value);
if ($value === null) {
return null;
}

if (is_array($value)) {
array_walk_recursive($value, function (?string &$val) {
/** @var mixed */
$val = $this->phpTypecastValue($val);
});
} else {
return null;
if (is_string($value)) {
return $this->getArrayParser()->parse($value);
}

return $value;
Expand All @@ -97,6 +107,27 @@ public function phpTypecast(mixed $value): mixed
return $this->phpTypecastValue($value);
}

/**
* Cast mixed value to PHP boolean type
*
* @param mixed $value
* @return bool|null
*/
public static function castBooleanValue(mixed $value): ?bool
{
if (is_bool($value) || $value === null) {
return $value;
}
/** @var mixed $value */
$value = is_string($value) ? strtolower($value) : $value;

return match ($value) {
't', 'true' => true,
'f', 'false' => false,
default => (bool) $value,
};
}

/**
* Casts $value after retrieving from the DBMS to PHP representation.
*
Expand All @@ -112,21 +143,21 @@ protected function phpTypecastValue(mixed $value): mixed
return null;
}

switch ($this->getType()) {
case AbstractSchema::TYPE_BOOLEAN:
/** @var mixed */
$value = is_string($value) ? strtolower($value) : $value;

return match ($value) {
't', 'true' => true,
'f', 'false' => false,
default => (bool)$value,
};
case AbstractSchema::TYPE_JSON:
return json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
if ($this->dimension > 0) {
return match ($this->getPhpArrayType()) {
AbstractSchema::PHP_TYPE_INTEGER => is_int($value) ? $value : (int) $value,
AbstractSchema::PHP_TYPE_DOUBLE => is_float($value) ? $value : (float) $value,
AbstractSchema::PHP_TYPE_BOOLEAN => self::castBooleanValue($value),
AbstractSchema::PHP_TYPE_ARRAY => json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR),
default => $value,
};
}

return parent::phpTypecast($value);
return match ($this->getType()) {
AbstractSchema::TYPE_BOOLEAN => self::castBooleanValue($value),
AbstractSchema::TYPE_JSON => json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR),
default => parent::phpTypecast($value),
};
}

/**
Expand All @@ -136,7 +167,7 @@ protected function phpTypecastValue(mixed $value): mixed
*/
protected function getArrayParser(): ArrayParser
{
return new ArrayParser();
return (new ArrayParser)->withTypeCast($this->getPhpArrayType());
}

/**
Expand Down
1 change: 0 additions & 1 deletion src/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
use Yiisoft\Db\Schema\ColumnSchemaInterface;
use Yiisoft\Db\Schema\Schema as AbstractSchema;
use Yiisoft\Db\Schema\TableSchemaInterface;

use function array_change_key_case;
use function array_merge;
use function array_unique;
Expand Down
48 changes: 47 additions & 1 deletion tests/ColumnSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@

use Yiisoft\Db\Expression\ArrayExpression;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Query\Query;
use Yiisoft\Db\Pgsql\ColumnSchema;
use Yiisoft\Db\Pgsql\Schema;
use Yiisoft\Db\Query\Query;

/**
* @group pgsql
Expand Down Expand Up @@ -76,4 +77,49 @@ public function testPhpTypeCastBool(): void
$this->assertFalse($columnSchema->phpTypeCast('false'));
$this->assertTrue($columnSchema->phpTypeCast('true'));
}

public function phpArrayDataProvider(): array
{
return [
[
'pgsql_arrays',
[
'_int' => Schema::PHP_TYPE_INTEGER,
'_text' => Schema::PHP_TYPE_STRING,
'_uuid' => Schema::PHP_TYPE_STRING,
'_date' => Schema::PHP_TYPE_STRING,
'_timestamp' => Schema::PHP_TYPE_STRING,
'_decimal' => Schema::PHP_TYPE_STRING, //Wrong only for that test/PR. It must be fixed in db/Schema
]
],
[
'array_and_json_types',
[
'intarray_col' => Schema::PHP_TYPE_INTEGER,
'textarray2_col' => Schema::PHP_TYPE_STRING,
'jsonarray_col' => Schema::PHP_TYPE_ARRAY,
]
]
];
}

/**
* @dataProvider phpArrayDataProvider
* @param string $tableName
* @param string[] $columns
* @return void
*/
public function testPhpArrayType(string $tableName, array $columns): void
{
$tableSchema = $this->getConnection(true)
->getSchema()
->getTableSchema($tableName);

foreach ($columns as $column => $phpType) {
/** @var ColumnSchema $columnSchema */
$columnSchema = $tableSchema->getColumn($column);
$this->assertEquals(Schema::PHP_TYPE_ARRAY, $columnSchema->getPhpType());
$this->assertEquals($phpType, $columnSchema->getPhpArrayType());
}
}
}
13 changes: 13 additions & 0 deletions tests/Fixture/postgres.sql
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ DROP TABLE IF EXISTS "T_constraints_2";
DROP TABLE IF EXISTS "T_constraints_1";
DROP TABLE IF EXISTS "T_upsert";
DROP TABLE IF EXISTS "T_upsert_1";
DROP TABLE IF EXISTS "pgsql_arrays";

DROP SCHEMA IF EXISTS "schema1" CASCADE;
DROP SCHEMA IF EXISTS "schema2" CASCADE;
Expand Down Expand Up @@ -431,3 +432,15 @@ CREATE TABLE "T_upsert_1"
(
"a" INT NOT NULL PRIMARY KEY
);

CREATE TABLE "pgsql_arrays"
(
"id" SERIAL NOT NULL PRIMARY KEY,
"_int" integer[],
"_text" varchar[],
"_uuid" uuid[],
"_date" date[],
"_timestamp" timestamp[],
"_decimal" decimal[],
"_int3" integer[3]
);
15 changes: 7 additions & 8 deletions tests/SchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
use Yiisoft\Db\QueryBuilder\QueryBuilder;
use Yiisoft\Db\Schema\TableSchemaInterface;
use Yiisoft\Db\TestSupport\TestSchemaTrait;

use function array_map;
use function fclose;
use function fopen;
Expand Down Expand Up @@ -243,7 +242,7 @@ public function getExpectedColumns(): array
'intarray_col' => [
'type' => 'integer',
'dbType' => 'int4',
'phpType' => 'integer',
'phpType' => 'array',
'allowNull' => true,
'autoIncrement' => false,
'enumValues' => null,
Expand All @@ -256,7 +255,7 @@ public function getExpectedColumns(): array
'textarray2_col' => [
'type' => 'text',
'dbType' => 'text',
'phpType' => 'string',
'phpType' => 'array',
'allowNull' => true,
'autoIncrement' => false,
'enumValues' => null,
Expand Down Expand Up @@ -749,11 +748,11 @@ public function testTableSchemaWithDbSchemes(string $tableName, string $expected
->method('createCommand')
->with(self::callback(function ($sql) {
return true;
}), self::callback(function ($params) use ($expectedTableName, $expectedSchemaName) {
$this->assertEquals($expectedTableName, $params[':tableName']);
$this->assertEquals($expectedSchemaName, $params[':schemaName']);
return true;
}))
}), self::callback(function ($params) use ($expectedTableName, $expectedSchemaName) {
$this->assertEquals($expectedTableName, $params[':tableName']);
$this->assertEquals($expectedSchemaName, $params[':schemaName']);
return true;
}))
->willReturn($commandMock);

$schema = new Schema($mockDb, $this->createSchemaCache());
Expand Down