From 39ec0a4f1323948adb3c547aeef3554b5b903177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 2 Aug 2023 21:31:56 +0300 Subject: [PATCH] Range and MultiRange parser --- src/ColumnSchema.php | 33 ++- src/MultiRangeParser.php | 96 +++++++ src/RangeParser.php | 185 +++++++++++++ src/Schema.php | 18 +- tests/ColumnSchemaTest.php | 46 ++- tests/MultiRangeParserTest.php | 363 ++++++++++++++++++++++++ tests/Provider/SchemaProvider.php | 84 ++++++ tests/RangeParserTest.php | 445 ++++++++++++++++++++++++++++++ tests/Support/Fixture/pgsql.sql | 8 +- tests/Support/Fixture/pgsql14.sql | 9 + 10 files changed, 1283 insertions(+), 4 deletions(-) create mode 100644 src/MultiRangeParser.php create mode 100644 src/RangeParser.php create mode 100644 tests/MultiRangeParserTest.php create mode 100644 tests/RangeParserTest.php create mode 100644 tests/Support/Fixture/pgsql14.sql diff --git a/src/ColumnSchema.php b/src/ColumnSchema.php index db39d788b..33ed35177 100644 --- a/src/ColumnSchema.php +++ b/src/ColumnSchema.php @@ -12,7 +12,6 @@ use Yiisoft\Db\Expression\JsonExpression; use Yiisoft\Db\Schema\AbstractColumnSchema; use Yiisoft\Db\Schema\SchemaInterface; - use function array_walk_recursive; use function bindec; use function decbin; @@ -105,6 +104,14 @@ public function dbTypecast(mixed $value): mixed */ public function phpTypecast(mixed $value): mixed { + if (is_string($value) && $rangeParser = $this->getRangeParser()) { + return $rangeParser->parse($value); + } + + if (is_string($value) && $multiRangeParser = $this->getMultiRangeParser()) { + return $multiRangeParser->parse($value); + } + if ($this->dimension > 0) { if (is_string($value)) { $value = $this->getArrayParser()->parse($value); @@ -156,6 +163,30 @@ private function getArrayParser(): ArrayParser return new ArrayParser(); } + /** + * @psalm-suppress PossiblyNullArgument + */ + private function getRangeParser(): ?RangeParser + { + if ($this->getDbType() !== null && RangeParser::isAllowedType($this->getDbType())) { + return new RangeParser($this->getDbType()); + } + + return null; + } + + /** + * @psalm-suppress PossiblyNullArgument + */ + private function getMultiRangeParser(): ?MultiRangeParser + { + if ($this->getDbType() !== null && MultiRangeParser::isAllowedType($this->getDbType())) { + return new MultiRangeParser($this->getDbType()); + } + + return null; + } + /** * @return int Get the dimension of the array. * diff --git a/src/MultiRangeParser.php b/src/MultiRangeParser.php new file mode 100644 index 000000000..08624c4ad --- /dev/null +++ b/src/MultiRangeParser.php @@ -0,0 +1,96 @@ + Schema::TYPE_INT_RANGE, + Schema::TYPE_BIGINT_MULTIRANGE => Schema::TYPE_BIGINT_RANGE, + Schema::TYPE_NUM_MULTIRANGE => Schema::TYPE_NUM_RANGE, + Schema::TYPE_DATE_MULTIRANGE => Schema::TYPE_DATE_RANGE, + Schema::TYPE_TS_MULTIRANGE => Schema::TYPE_TS_RANGE, + Schema::TYPE_TS_TZ_MULTIRANGE => Schema::TYPE_TS_TZ_RANGE, + ]; + + private ?string $type = null; + + public function __construct(?string $type = null) + { + $this->type = $type; + } + + public function withType(?string $type): self + { + $new = clone $this; + $new->type = $type; + + return $new; + } + + public function asMultiInt(): self + { + return $this->withType(Schema::TYPE_INT_MULTIRANGE); + } + + public function asMultiBigInt(): self + { + return $this->withType(Schema::TYPE_BIGINT_MULTIRANGE); + } + + public function asMultiNumeric(): self + { + return $this->withType(Schema::TYPE_NUM_MULTIRANGE); + } + + public function asMultiDate(): self + { + return $this->withType(Schema::TYPE_DATE_MULTIRANGE); + } + + public function asMultiTimestamp(): self + { + return $this->withType(Schema::TYPE_TS_MULTIRANGE); + } + + public function asMultiTimestampTz(): self + { + return $this->withType(Schema::TYPE_TS_TZ_MULTIRANGE); + } + + public function asCustom(): self + { + return $this->withType(null); + } + + public function parse(?string $value): ?array + { + if ($value === null) { + return null; + } + + if ($value === '{}') { + return []; + } + + if (!preg_match_all('/(([\[\(][^,]*,[^\)\]]*[\)\]]),?)+/U', $value, $matches) || $value !== '{' . implode(',', $matches[1]) . '}') { + throw new InvalidArgumentException('Unsupported range format'); + } + + $type = self::RANGES[$this->type] ?? $this->type; + $parser = new RangeParser($type); + + return array_map([$parser, 'parse'], $matches[1]); + } + + public static function isAllowedType(string $type): bool + { + return isset(self::RANGES[$type]); + } +} diff --git a/src/RangeParser.php b/src/RangeParser.php new file mode 100644 index 000000000..4ce66a2b6 --- /dev/null +++ b/src/RangeParser.php @@ -0,0 +1,185 @@ +type = $type; + } + + public function withType(?string $type): self + { + $new = clone $this; + $new->type = $type; + + return $new; + } + + public function asInt(): self + { + return $this->withType(Schema::TYPE_INT_RANGE); + } + + public function asBigInt(): self + { + return $this->withType(Schema::TYPE_BIGINT_RANGE); + } + + public function asNumeric(): self + { + return $this->withType(Schema::TYPE_NUM_RANGE); + } + + public function asDate(): self + { + return $this->withType(Schema::TYPE_DATE_RANGE); + } + + public function asTimestamp(): self + { + return $this->withType(Schema::TYPE_TS_RANGE); + } + + public function asTimestampTz(): self + { + return $this->withType(Schema::TYPE_TS_TZ_RANGE); + } + + public function asCustom(): self + { + return $this->withType(null); + } + + public function parse(?string $value): ?array + { + if ($value === null || $value === 'empty') { + return null; + } + + if (!preg_match('/^(?P\[|\()(?P[^,]*),(?P[^\)\]]*)(?P\)|\])$/', $value, $matches)) { + throw new InvalidArgumentException('Unsupported range format'); + } + + $lower = $matches['lower'] ? trim($matches['lower'], '"') : null; + $upper = $matches['upper'] ? trim($matches['upper'], '"') : null; + $includeLower = $matches['open'] === '['; + $includeUpper = $matches['close'] === ']'; + + if ($lower === null && $upper === null) { + return [null, null]; + } + + return match($this->type) { + Schema::TYPE_INT_RANGE => self::parseIntRange($lower, $upper, $includeLower, $includeUpper), + Schema::TYPE_BIGINT_RANGE => self::parseBigIntRange($lower, $upper, $includeLower, $includeUpper), + Schema::TYPE_NUM_RANGE => self::parseNumRange($lower, $upper), + Schema::TYPE_DATE_RANGE => self::parseDateRange($lower, $upper, $includeLower, $includeUpper), + Schema::TYPE_TS_RANGE => self::parseTsRange($lower, $upper), + Schema::TYPE_TS_TZ_RANGE => self::parseTsTzRange($lower, $upper), + default => [$lower, $upper] + }; + } + + private static function parseIntRange(?string $lower, ?string $upper, bool $includeLower, bool $includeUpper): array + { + $min = $lower === null ? null : (int) $lower; + $max = $upper === null ? null : (int) $upper; + + if ($min !== null && $includeLower === false) { + $min += 1; + } + + if ($max !== null && $includeUpper === false) { + $max -= 1; + } + + return [$min, $max]; + } + + private static function parseBigIntRange(?string $lower, ?string $upper, bool $includeLower, bool $includeUpper): array + { + if (PHP_INT_SIZE === 8) { + return self::parseIntRange($lower, $upper, $includeLower, $includeUpper); + } + + [$min, $max] = self::parseNumRange($lower, $upper); + + if ($min !== null && $includeLower === false) { + /** @var float $min */ + $min += 1; + } + + if ($max !== null && $includeUpper === false) { + /** @var float $max */ + $max -= 1; + } + + return [$min, $max]; + } + + private static function parseNumRange(?string $lower, ?string $upper): array + { + $min = $lower === null ? null : (float) $lower; + $max = $upper === null ? null : (float) $upper; + + return [$min, $max]; + } + + private static function parseDateRange(?string $lower, ?string $upper, bool $includeLower, bool $includeUpper): array + { + $interval = new DateInterval('P1D'); + $min = $lower ? DateTime::createFromFormat('Y-m-d', $lower) : null; + $max = $upper ? DateTime::createFromFormat('Y-m-d', $upper) : null; + + if ($min && $includeLower === false) { + $min->add($interval); + } + + if ($max && $includeUpper === false) { + $max->sub($interval); + } + + return [$min, $max]; + } + + private static function parseTsRange(?string $lower, ?string $upper): array + { + $min = $lower ? DateTime::createFromFormat('Y-m-d H:i:s', $lower) : null; + $max = $upper ? DateTime::createFromFormat('Y-m-d H:i:s', $upper) : null; + + return [$min, $max]; + } + + private static function parseTsTzRange(?string $lower, ?string $upper): array + { + $min = $lower ? DateTime::createFromFormat('Y-m-d H:i:sP', $lower) : null; + $max = $upper ? DateTime::createFromFormat('Y-m-d H:i:sP', $upper) : null; + + return [$min, $max]; + } + + public static function isAllowedType(string $type): bool + { + return in_array($type, self::RANGES, true); + } +} diff --git a/src/Schema.php b/src/Schema.php index f40a3fa36..cf6cbb2d6 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -20,7 +20,6 @@ use Yiisoft\Db\Schema\Builder\ColumnInterface; use Yiisoft\Db\Schema\ColumnSchemaInterface; use Yiisoft\Db\Schema\TableSchemaInterface; - use function array_merge; use function array_unique; use function array_values; @@ -86,6 +85,23 @@ final class Schema extends AbstractPdoSchema */ public const TYPE_BIT = 'bit'; + /** + * Define the abstract range columns types + * @see https://www.postgresql.org/docs/current/rangetypes.html + */ + public const TYPE_INT_RANGE = 'int4range'; + public const TYPE_BIGINT_RANGE = 'int8range'; + public const TYPE_NUM_RANGE = 'numrange'; + public const TYPE_TS_RANGE = 'tsrange'; + public const TYPE_TS_TZ_RANGE = 'tstzrange'; + public const TYPE_DATE_RANGE = 'daterange'; + public const TYPE_INT_MULTIRANGE = 'int4multirange'; + public const TYPE_BIGINT_MULTIRANGE = 'int8multirange'; + public const TYPE_NUM_MULTIRANGE = 'nummultirange'; + public const TYPE_TS_MULTIRANGE = 'tsmultirange'; + public const TYPE_TS_TZ_MULTIRANGE = 'tstzmultirange'; + public const TYPE_DATE_MULTIRANGE = 'datemultirange'; + /** * @var array The mapping from physical column types (keys) to abstract column types (values). * diff --git a/tests/ColumnSchemaTest.php b/tests/ColumnSchemaTest.php index 2c8195acb..0f989547f 100644 --- a/tests/ColumnSchemaTest.php +++ b/tests/ColumnSchemaTest.php @@ -16,8 +16,10 @@ use Yiisoft\Db\Pgsql\Tests\Support\TestTrait; use Yiisoft\Db\Query\Query; use Yiisoft\Db\Schema\SchemaInterface; - +use function str_repeat; use function stream_get_contents; +use function version_compare; +use const PHP_INT_SIZE; /** * @group pgsql @@ -59,6 +61,9 @@ public function testPhpTypeCast(): void 'json_col' => [['a' => 1, 'b' => null, 'c' => [1, 3, 5]]], 'jsonb_col' => new JsonExpression(new ArrayExpression([1, 2, 3])), 'jsonarray_col' => [new ArrayExpression([[',', 'null', true, 'false', 'f']], SchemaInterface::TYPE_JSON)], + 'intrange_col' => new Expression("'[3,7)'::int4range"), + 'bigintrange_col' => new Expression("'[2147483648,2147483649]'::int8range"), + 'numrange_col' => new Expression("'(1.1,1.5)'::numrange"), ] ); $command->execute(); @@ -81,6 +86,9 @@ public function testPhpTypeCast(): void $jsonColPhpType = $tableSchema->getColumn('json_col')?->phpTypecast($query['json_col']); $jsonBColPhpType = $tableSchema->getColumn('jsonb_col')?->phpTypecast($query['jsonb_col']); $jsonArrayColPhpType = $tableSchema->getColumn('jsonarray_col')?->phpTypecast($query['jsonarray_col']); + $intRangePhpType = $tableSchema->getColumn('intrange_col')->phpTypecast($query['intrange_col']); + $bigIntRangephpType = $tableSchema->getColumn('bigintrange_col')->phpTypecast($query['bigintrange_col']); + $numRangePhpType = $tableSchema->getColumn('numrange_col')->phpTypecast($query['numrange_col']); $this->assertSame(1, $intColPhpTypeCast); $this->assertSame(str_repeat('x', 100), $charColPhpTypeCast); @@ -97,10 +105,46 @@ public function testPhpTypeCast(): void $this->assertSame([['a' => 1, 'b' => null, 'c' => [1, 3, 5]]], $jsonColPhpType); $this->assertSame(['1', '2', '3'], $jsonBColPhpType); $this->assertSame([[[',', 'null', true, 'false', 'f']]], $jsonArrayColPhpType); + $this->assertSame([3, 6], $intRangePhpType); + $this->assertSame(PHP_INT_SIZE === 8 ? [2147483648, 2147483649] : [2147483648.0, 2147483649.0], $bigIntRangephpType); + $this->assertSame([1.1, 1.5], $numRangePhpType); $db->close(); } + public function testPhpTypeCaseMultiRange(): void + { + if (version_compare($this->getConnection()->getServerVersion(), '14.0', '<')) { + $this->markTestSkipped('PostgreSQL < 14.0 does not support multi range columns.'); + } + + $this->setFixture('pgsql14.sql'); + $db = $this->getConnection(true); + + $command = $db->createCommand(); + $schema = $db->getSchema(); + $tableSchema = $schema->getTableSchema('table_with_multirange'); + $command->insert( + 'table_with_multirange', + [ + 'int4multirange_col' => new Expression("'{[3,7), [8,9)}'::int4multirange"), + 'int8multirange_col' => new Expression("'{[2147483648,2147483649]}'::int8multirange"), + 'nummultirange_col' => new Expression("'{[10.5,15.2],(20.7,21),(38.1,39.3]}'::nummultirange"), + 'datemultirange_col' => new Expression("'{[2020-12-01,2021-01-01],[2020-12-02,2021-01-03)}'::datemultirange"), + ] + ); + $command->execute(); + $query = (new Query($db))->from('table_with_multirange')->one(); + + $this->assertNotNull($tableSchema); + + $intColPhpTypeCast = $tableSchema->getColumn('int4multirange_col')?->phpTypecast($query['int4multirange_col']); + $floatColPhpTypeCast = $tableSchema->getColumn('nummultirange_col')?->phpTypecast($query['nummultirange_col']); + + $this->assertSame([[3,6], [8,8]], $intColPhpTypeCast); + $this->assertSame([[10.5, 15.2], [20.7, 21.0], [38.1, 39.3]], $floatColPhpTypeCast); + } + /** * @throws JsonException */ diff --git a/tests/MultiRangeParserTest.php b/tests/MultiRangeParserTest.php new file mode 100644 index 000000000..6c3e72a5b --- /dev/null +++ b/tests/MultiRangeParserTest.php @@ -0,0 +1,363 @@ +asMultiInt()->parse($value); + + $this->assertSame($expected, $result); + } + + public static function dateRangeDataProvider(): array + { + return [ + [ + [ + [ + new DateTime('2020-12-01'), + new DateTime('2021-01-01'), + ], + + [ + new DateTime('2020-12-01'), + new DateTime('2021-01-02'), + ], + + [ + new DateTime('2021-01-01'), + new DateTime('2021-01-02'), + ], + + [ + null, + new DateTime('2021-01-02'), + ], + + [ + new DateTime('2020-12-01'), + null, + ], + ], + '{[2020-12-01,2021-01-01],[2020-12-01,2021-01-03),(2020-12-31,2021-01-03),(,2021-01-03),[2020-12-01,)}', + ], + ]; + } + + /** + * @dataProvider dateRangeDataProvider + * @param array $expected + * @param string $value + * @return void + */ + public function testDateMultiRange(array $expected, string $value): void + { + $parser = new MultiRangeParser(); + $result = $parser->asMultiDate()->parse($value); + + $this->assertCount(count($expected), $result); + + foreach ($expected as $i => $date) { + $this->assertCount(2, $result[$i]); + + $lower = $date[0]; + $upper = $date[1]; + $min = $result[$i][0]; + $max = $result[$i][1]; + + if ($lower === null) { + $this->assertNull($min); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $min); + $this->assertSame($lower->format('Y-m-d'), $min->format('Y-m-d')); + } + + if ($upper === null) { + $this->assertNull($max); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $max); + $this->assertSame($upper->format('Y-m-d'), $max->format('Y-m-d')); + } + } + } + + public static function numRangeDataProvider(): array + { + return [ + [ + [ + [10.5, 20.7], + [null, 39.3], + [11.2, null], + [null, null], + ], + '{[10.5,20.7],(,39.3],(11.2,),[,]}', + ], + ]; + } + + /** + * @dataProvider numRangeDataProvider + * @param array $expected + * @param string $value + * @return void + */ + public function testNumMultiRange(array $expected, string $value): void + { + $parser = new MultiRangeParser(); + $result = $parser->asMultiNumeric()->parse($value); + + $this->assertSame($expected, $result); + } + + public static function bigIntDataProvider(): array + { + return [ + [ + [ + [ + PHP_INT_SIZE === 8 ? (int) '2147483648' : (float) '2147483648', + PHP_INT_SIZE === 8 ? (int) '2147483649' : (float) '2147483649', + ], + [ + null, + PHP_INT_SIZE === 8 ? (int) '2147483648' : (float) '2147483648', + ], + ], + '{[2147483648,2147483649],[,2147483649)}', + ], + ]; + } + + /** + * @dataProvider bigIntDataProvider + * @param array $expected + * @param string $value + * @return void + */ + public function testBigIntMultiRange(array $expected, string $value): void + { + $parser = new MultiRangeParser(); + $result = $parser->asMultiBigInt()->parse($value); + + $this->assertSame($expected, $result); + } + + public static function timestampDataProvider(): array + { + return [ + [ + [ + [ + new DateTime('2023-08-07 13:00:00'), + new DateTime('2023-08-07 13:30:00'), + ], + [ + null, + new DateTime('2023-08-07 13:30:00'), + ], + [ + new DateTime('2023-08-07 13:00:00'), + null, + ], + ], + + '{[2023-08-07 13:00:00,2023-08-07 13:30:00],[,2023-08-07 13:30:00],[2023-08-07 13:00:00,]}' + ], + ]; + } + + /** + * @dataProvider timestampDataProvider + * @param array $expected + * @param string $value + * @return void + */ + public function testTimestampMultiRange(array $expected, string $value): void + { + $parser = new MultiRangeParser(); + $result = $parser->asMultiTimestamp()->parse($value); + + $this->assertCount(count($expected), $result); + + foreach ($expected as $i => $date) { + + $res = $result[$i]; + + $this->assertCount(count($date), $res); + + $lower = $res[0]; + $upper = $res[1]; + $min = $date[0]; + $max = $date[1]; + + if ($min === null) { + $this->assertNull($lower); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $lower); + $this->assertSame($min->format('U'), $lower->format('U')); + } + + if ($max === null) { + $this->assertNull($upper); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $upper); + $this->assertSame($max->format('U'), $upper->format('U')); + } + } + } + + public static function timestampTzDataProvider(): array + { + return [ + [ + [ + [ + DateTime::createFromFormat('Y-m-d H:i:sP', '2023-08-07 13:00:00+03'), + DateTime::createFromFormat('Y-m-d H:i:sP', '2023-08-07 13:30:00+03'), + ], + + [ + null, + DateTime::createFromFormat('Y-m-d H:i:sP', '2023-08-07 13:30:00+03'), + ], + + [ + DateTime::createFromFormat('Y-m-d H:i:sP', '2023-08-07 13:00:00+03'), + null, + ], + ], + + '{[2023-08-07 13:00:00+03,2023-08-07 13:30:00+03],[,2023-08-07 13:30:00+03],[2023-08-07 13:00:00+03,]}' + ], + ]; + } + + /** + * @dataProvider timestampTzDataProvider + * @param array $expected + * @param string $value + * @return void + */ + public function testTimestampTzMultiRange(array $expected, string $value): void + { + $parser = new MultiRangeParser(); + $result = $parser->asMultiTimestampTz()->parse($value); + + $this->assertCount(count($expected), $result); + + foreach ($expected as $i => $date) { + + $res = $result[$i]; + + $this->assertCount(count($date), $res); + + $lower = $res[0]; + $upper = $res[1]; + $min = $date[0]; + $max = $date[1]; + + if ($min === null) { + $this->assertNull($lower); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $lower); + $this->assertSame($min->format('U'), $lower->format('U')); + } + + if ($max === null) { + $this->assertNull($upper); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $upper); + $this->assertSame($max->format('U'), $upper->format('U')); + } + } + } + + public static function customDataProvider(): array + { + return [ + [ + [ + ['a', 'f'], + ['from', 'to'], + [null, 'to'], + ['from', null], + ], + '{[a,f],[from,to],[,to],[from,]}', + ] + ]; + } + + /** + * @dataProvider customDataProvider + * @param array $expected + * @param string $value + * @return void + */ + public function testCustomMultiRange(array $expected, string $value): void + { + $parser = (new MultiRangeParser())->asCustom(); + $result = $parser->parse($value); + + $this->assertSame($expected, $result); + } + + public static function exceptionDataProvider(): array + { + return [ + ['{,2147483649)}'], + ['test'], + ['{[2147483648,2147483649,[,2147483649)}}'] + ]; + } + + /** + * @dataProvider exceptionDataProvider + * @param string $value + * @return void + */ + public function testExceptions(string $value): void + { + $this->expectException(InvalidArgumentException::class); + + (new RangeParser())->asCustom()->parse($value); + } +} diff --git a/tests/Provider/SchemaProvider.php b/tests/Provider/SchemaProvider.php index 62ee902c0..ae6e97a7b 100644 --- a/tests/Provider/SchemaProvider.php +++ b/tests/Provider/SchemaProvider.php @@ -358,6 +358,90 @@ public static function columns(): array 'defaultValue' => null, 'dimension' => 1, ], + 'intrange_col' => [ + 'type' => 'string', + 'dbType' => 'int4range', + 'phpType' => 'string', + 'primaryKey' => false, + 'allowNull' => true, + 'autoIncrement' => false, + 'enumValues' => null, + 'size' => null, + 'precision' => null, + 'scale' => null, + 'defaultValue' => null, + 'dimension' => 0, + ], + 'bigintrange_col' => [ + 'type' => 'string', + 'dbType' => 'int8range', + 'phpType' => 'string', + 'primaryKey' => false, + 'allowNull' => true, + 'autoIncrement' => false, + 'enumValues' => null, + 'size' => null, + 'precision' => null, + 'scale' => null, + 'defaultValue' => null, + 'dimension' => 0, + ], + 'numrange_col' => [ + 'type' => 'string', + 'dbType' => 'numrange', + 'phpType' => 'string', + 'primaryKey' => false, + 'allowNull' => true, + 'autoIncrement' => false, + 'enumValues' => null, + 'size' => null, + 'precision' => null, + 'scale' => null, + 'defaultValue' => null, + 'dimension' => 0, + ], + 'daterange_col' => [ + 'type' => 'string', + 'dbType' => 'daterange', + 'phpType' => 'string', + 'primaryKey' => false, + 'allowNull' => true, + 'autoIncrement' => false, + 'enumValues' => null, + 'size' => null, + 'precision' => null, + 'scale' => null, + 'defaultValue' => null, + 'dimension' => 0, + ], + 'tsrange_col' => [ + 'type' => 'string', + 'dbType' => 'tsrange', + 'phpType' => 'string', + 'primaryKey' => false, + 'allowNull' => true, + 'autoIncrement' => false, + 'enumValues' => null, + 'size' => null, + 'precision' => null, + 'scale' => null, + 'defaultValue' => null, + 'dimension' => 0, + ], + 'tstzrange_col' => [ + 'type' => 'string', + 'dbType' => 'tstzrange', + 'phpType' => 'string', + 'primaryKey' => false, + 'allowNull' => true, + 'autoIncrement' => false, + 'enumValues' => null, + 'size' => null, + 'precision' => null, + 'scale' => null, + 'defaultValue' => null, + 'dimension' => 0, + ], ], 'tableName' => 'type', ], diff --git a/tests/RangeParserTest.php b/tests/RangeParserTest.php new file mode 100644 index 000000000..1abd1aa12 --- /dev/null +++ b/tests/RangeParserTest.php @@ -0,0 +1,445 @@ +parse($value); + + $this->assertNull($result); + $this->assertNull($parser->asCustom()->parse($value)); + + foreach (self::TYPES as $type) { + $this->assertNull($parser->withType($type)->parse($value)); + + switch ($type) { + case Schema::TYPE_INT_RANGE: + $this->assertNull($parser->asInt()->parse($value)); + break; + case Schema::TYPE_BIGINT_RANGE: + $this->assertNull($parser->asBigInt()->parse($value)); + break; + case Schema::TYPE_NUM_RANGE: + $this->assertNull($parser->asNumeric()->parse($value)); + break; + case Schema::TYPE_DATE_RANGE: + $this->assertNull($parser->asDate()->parse($value)); + break; + case Schema::TYPE_TS_RANGE: + $this->assertNull($parser->asTimestamp()->parse($value)); + break; + case Schema::TYPE_TS_TZ_RANGE: + $this->assertNull($parser->asTimestampTz()->parse($value)); + break; + } + } + } + + public static function intRangeDataProvider(): array + { + return [ + [ + [1, 10], + '[1,11)', + ], + + [ + [100, 120], + '[100,120]', + ], + + [ + [null, null], + '(,)', + ], + + [ + [0, 5], + '(-1,6)', + ], + + [ + [5, null], + '[5,)', + ], + + [ + [null, 7], + '[,8)', + ], + ]; + } + + /** + * @dataProvider intRangeDataProvider + * @return void + * @throws \Throwable + * @throws \Yiisoft\Db\Exception\Exception + * @throws \Yiisoft\Db\Exception\InvalidConfigException + */ + public function testIntRangeParser(array $expected, string $value): void + { + $parser = new RangeParser(); + $result = $parser->asInt()->parse($value); + + $this->assertSame($expected, $result); + } + + public static function bigIntDataProvider(): array + { + return [ + [ + [ + PHP_INT_SIZE === 8 ? (int) '2147483648' : (float) '2147483648', + PHP_INT_SIZE === 8 ? (int) '2147483649' : (float) '2147483649', + ], + '[2147483648,2147483649]', + ], + + [ + [ + null, + PHP_INT_SIZE === 8 ? (int) '2147483648' : (float) '2147483648', + ], + '[,2147483649)', + ], + ]; + } + + /** + * @dataProvider bigIntDataProvider + * @param string $value + * @return void + */ + public function testBigIntRange(array $expected, string $value): void + { + $parser = (new RangeParser())->asBigInt(); + $result = $parser->parse($value); + + $this->assertSame($expected, $result); + } + + public static function numRangeDataProvider(): array + { + return [ + [ + [10.5, 20.7], + '[10.5,20.7]', + ], + + [ + [null, 39.3], + '(,39.3]', + ], + + [ + [11.2, null], + '(11.2,)', + ], + + [ + [null, null], + '[,]', + ], + ]; + } + + /** + * @dataProvider numRangeDataProvider + * @param array $expected + * @param string $value + * @return void + */ + public function testNumRangeParser(array $expected, string $value): void + { + $parser = new RangeParser(); + $result = $parser->asNumeric()->parse($value); + + $this->assertSame($expected, $result); + } + + + public static function dateRangeDataProvider(): array + { + return [ + [ + new DateTime('2020-12-01'), + new DateTime('2021-01-01'), + '[2020-12-01,2021-01-01]', + ], + + [ + new DateTime('2020-12-01'), + new DateTime('2021-01-02'), + '[2020-12-01,2021-01-03)', + ], + + [ + new DateTime('2021-01-01'), + new DateTime('2021-01-02'), + '(2020-12-31,2021-01-03)', + ], + + [ + null, + new DateTime('2021-01-02'), + '(,2021-01-03)', + ], + + [ + new DateTime('2020-12-01'), + null, + '[2020-12-01,)', + ], + ]; + } + + /** + * @dataProvider dateRangeDataProvider + * @param DateTimeInterface|null $lower + * @param DateTimeInterface|null $upper + * @param string $value + * @return void + */ + public function testDateRangeParser(?DateTimeInterface $lower, ?DateTimeInterface $upper, string $value): void + { + $parser = new RangeParser(Schema::TYPE_DATE_RANGE); + $result = $parser->parse($value); + + $this->assertCount(2, $result); + + $min = $result[0]; + $max = $result[1]; + + if ($lower === null) { + $this->assertNull($min); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $min); + $this->assertSame($lower->format('Y-m-d'), $min->format('Y-m-d')); + } + + if ($upper === null) { + $this->assertNull($max); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $max); + $this->assertSame($upper->format('Y-m-d'), $max->format('Y-m-d')); + } + } + + public static function timestampDataProvider(): array + { + return [ + [ + [ + new DateTime('2023-08-07 13:00:00'), + new DateTime('2023-08-07 13:30:00'), + ], + '[2023-08-07 13:00:00,2023-08-07 13:30:00]' + ], + [ + [ + null, + new DateTime('2023-08-07 13:30:00'), + ], + '[,2023-08-07 13:30:00]' + ], + [ + [ + new DateTime('2023-08-07 13:00:00'), + null, + ], + '[2023-08-07 13:00:00,]' + ], + ]; + } + + /** + * @dataProvider timestampDataProvider + * @param DateTime[]|null[] $expected + * @param string $value + * @return void + */ + public function testTimestampRange(array $expected, string $value): void + { + $parser = new RangeParser(); + $result = $parser->asTimestamp()->parse($value); + + $this->assertCount(2, $result); + + $lower = $result[0]; + $upper = $result[1]; + $min = $expected[0]; + $max = $expected[1]; + + if ($min === null) { + $this->assertNull($lower); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $lower); + $this->assertSame($min->format('U'), $lower->format('U')); + } + + if ($max === null) { + $this->assertNull($upper); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $upper); + $this->assertSame($max->format('U'), $upper->format('U')); + } + } + + public static function timestampTzDataProvider(): array + { + return [ + [ + [ + DateTime::createFromFormat('Y-m-d H:i:sP', '2023-08-07 13:00:00+03'), + DateTime::createFromFormat('Y-m-d H:i:sP', '2023-08-07 13:30:00+03'), + ], + '[2023-08-07 13:00:00+03,2023-08-07 13:30:00+03]' + ], + [ + [ + null, + DateTime::createFromFormat('Y-m-d H:i:sP', '2023-08-07 13:30:00+03'), + ], + '[,2023-08-07 13:30:00+03]' + ], + [ + [ + DateTime::createFromFormat('Y-m-d H:i:sP', '2023-08-07 13:00:00+03'), + null, + ], + '[2023-08-07 13:00:00+03,]' + ], + ]; + } + + /** + * @dataProvider timestampTzDataProvider + * @param DateTimeInterface[]|null[] $expected + * @param string $value + * @return void + */ + public function testTimestampTzRange(array $expected, string $value): void + { + $parser = new RangeParser(); + $result = $parser->asTimestampTz()->parse($value); + + $this->assertCount(2, $result); + + $lower = $result[0]; + $upper = $result[1]; + $min = $expected[0]; + $max = $expected[1]; + + if ($min === null) { + $this->assertNull($lower); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $lower); + $this->assertSame($min->format('U'), $lower->format('U')); + } + + if ($max === null) { + $this->assertNull($upper); + } else { + $this->assertInstanceOf(DateTimeInterface::class, $upper); + $this->assertSame($max->format('U'), $upper->format('U')); + } + } + + public static function customDataProvider(): array + { + return [ + [ + ['a', 'f'], + '[a,f]', + ], + + [ + ['from', 'to'], + '[from,to]', + ], + + [ + [null, 'to'], + '[,to]', + ], + + [ + ['from', null], + '[from,]', + ] + ]; + } + + /** + * @dataProvider customDataProvider + * @param array $expected + * @param string $value + * @return void + */ + public function testCustomRange(array $expected, string $value): void + { + $parser = new RangeParser('my_custom_type'); + $result = $parser->parse($value); + $customResult = $parser->asCustom()->parse($value); + + $this->assertSame($expected, $result); + $this->assertSame($expected, $customResult); + } + + public static function exceptionDataProvider(): array + { + return [ + ['(10,15'], + ['test'], + ['{10,15}'], + ['11,]'], + ]; + } + + /** + * @dataProvider exceptionDataProvider + * @param string $value + * @return void + */ + public function testArgumentException(string $value): void + { + $this->expectException(InvalidArgumentException::class); + + (new RangeParser())->parse($value); + } +} diff --git a/tests/Support/Fixture/pgsql.sql b/tests/Support/Fixture/pgsql.sql index 97df69307..7b012ea6f 100644 --- a/tests/Support/Fixture/pgsql.sql +++ b/tests/Support/Fixture/pgsql.sql @@ -162,7 +162,13 @@ CREATE TABLE "type" ( textarray2_col text[][], json_col json DEFAULT '{"a":1}', jsonb_col jsonb, - jsonarray_col json[] + jsonarray_col json[], + intrange_col int4range, + bigintrange_col int8range, + numrange_col numrange, + daterange_col daterange, + tsrange_col tsrange, + tstzrange_col tstzrange ); CREATE TABLE "bool_values" ( diff --git a/tests/Support/Fixture/pgsql14.sql b/tests/Support/Fixture/pgsql14.sql new file mode 100644 index 000000000..1a7fc0992 --- /dev/null +++ b/tests/Support/Fixture/pgsql14.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS "table_with_multirange" CASCADE; + +CREATE TABLE "table_with_multirange" ( + id serial not null primary key, + int4multirange_col int4multirange, + int8multirange_col int8multirange, + nummultirange_col nummultirange, + datemultirange_col datemultirange +);