diff --git a/.gitignore b/.gitignore index ac9dbbfc6..617677cef 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ composer.phar # phpunit itself is not needed phpunit.phar phpunit.result.cache +.phpunit.result.cache # local phpunit config /phpunit.xml diff --git a/src/ArrayParser.php b/src/ArrayParser.php index 19d9891a8..3567a4aaa 100644 --- a/src/ArrayParser.php +++ b/src/ArrayParser.php @@ -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. @@ -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 * @@ -37,6 +45,14 @@ 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. * @@ -44,6 +60,7 @@ public function parse(?string $value): ?array * @param int $i parse starting position. * * @return array + * @throws JsonException */ private function parseArray(string $value, int &$i = 0): array { @@ -69,6 +86,7 @@ private function parseArray(string $value, int &$i = 0): array } break; default: + /** @var mixed */ $result[] = $this->parseString($value, $i); } } @@ -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, '}']; @@ -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, + }; } } diff --git a/src/ColumnSchema.php b/src/ColumnSchema.php index fcbe4433f..973b51586 100644 --- a/src/ColumnSchema.php +++ b/src/ColumnSchema.php @@ -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. @@ -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. * @@ -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; @@ -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. * @@ -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), + }; } /** @@ -136,7 +167,7 @@ protected function phpTypecastValue(mixed $value): mixed */ protected function getArrayParser(): ArrayParser { - return new ArrayParser(); + return (new ArrayParser)->withTypeCast($this->getPhpArrayType()); } /** diff --git a/src/Schema.php b/src/Schema.php index 03ca4e76e..e0bc8712d 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -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; diff --git a/tests/ColumnSchemaTest.php b/tests/ColumnSchemaTest.php index cc527b2db..ee9140845 100644 --- a/tests/ColumnSchemaTest.php +++ b/tests/ColumnSchemaTest.php @@ -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 @@ -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()); + } + } } diff --git a/tests/Fixture/postgres.sql b/tests/Fixture/postgres.sql index 446139028..7e0f47bfe 100644 --- a/tests/Fixture/postgres.sql +++ b/tests/Fixture/postgres.sql @@ -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; @@ -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] +); diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index db3ca18d9..be928ad35 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -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; @@ -243,7 +242,7 @@ public function getExpectedColumns(): array 'intarray_col' => [ 'type' => 'integer', 'dbType' => 'int4', - 'phpType' => 'integer', + 'phpType' => 'array', 'allowNull' => true, 'autoIncrement' => false, 'enumValues' => null, @@ -256,7 +255,7 @@ public function getExpectedColumns(): array 'textarray2_col' => [ 'type' => 'text', 'dbType' => 'text', - 'phpType' => 'string', + 'phpType' => 'array', 'allowNull' => true, 'autoIncrement' => false, 'enumValues' => null, @@ -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());