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/ColumnSchema.php b/src/ColumnSchema.php index a1915a17d..125817269 100644 --- a/src/ColumnSchema.php +++ b/src/ColumnSchema.php @@ -76,6 +76,10 @@ public function dbTypecast($value) */ public function phpTypecast($value) { + if (RangeParser::isAllowedType($this->getType())) { + return $this->getRangeParser()->parse($value); + } + if ($this->dimension > 0) { if (!is_array($value)) { $value = $this->getArrayParser()->parse($value); @@ -141,6 +145,16 @@ protected function getArrayParser(): ArrayParser return new ArrayParser(); } + /** + * Creates instance of RangeParser. + * + * @return RangeParser + */ + protected function getRangeParser(): RangeParser + { + return new RangeParser($this->getType()); + } + /** * @return int Get the dimension of array. Defaults to 0, means this column is not an array. */ diff --git a/src/RangeParser.php b/src/RangeParser.php new file mode 100644 index 000000000..e17b716c3 --- /dev/null +++ b/src/RangeParser.php @@ -0,0 +1,201 @@ +type = $type; + } else { + throw new InvalidArgumentException('Unsupported range type "' . $type . '"'); + } + } + } + + public function parse(?string $value): ?array + { + if ($value === null) { + return null; + } + + if (!preg_match('/^(?P\[|\()(?P[^,]*),(?P[^\)\]]*)(?P\)|\])$/', $value, $matches)) { + throw new InvalidArgumentException(); + } + + $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]; + } + + $type = $this->type ?? self::parseType($lower, $upper); + + switch ($type) { + case Schema::TYPE_INT_4_RANGE: + return self::parseIntRange($lower, $upper, $includeLower, $includeUpper); + case Schema::TYPE_INT_8_RANGE: + return self::parseBigIntRange($lower, $upper, $includeLower, $includeUpper); + case Schema::TYPE_NUM_RANGE: + return self::parseNumRange($lower, $upper, $includeLower, $includeUpper); + case Schema::TYPE_DATE_RANGE: + return self::parseDateRange($lower, $upper, $includeLower, $includeUpper); + case Schema::TYPE_TS_RANGE: + return self::parseTsRange($lower, $upper, $includeLower, $includeUpper); + case Schema::TYPE_TS_TZ_RANGE: + return self::parseTsTzRange($lower, $upper, $includeLower, $includeUpper); + default: + return null; + } + } + + 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, $includeLower, $includeUpper); + + if ($min !== null && $includeLower === false) { + $min += 1; + } + + if ($max !== null && $includeUpper === false) { + $max -= 1; + } + + return [$min, $max]; + } + + private static function parseNumRange(?string $lower, ?string $upper, bool $includeLower, bool $includeUpper): 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, bool $includeLower, bool $includeUpper): 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, bool $includeLower, bool $includeUpper): 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); + } + + /** + * Find range type from value format + * + * @param string $lower + * @param string $upper + * + * @return string|null + */ + private static function parseType(?string $lower, ?string $upper): ?string + { + if ($lower !== null && $upper !== null) { + if (filter_var($lower, FILTER_VALIDATE_INT) && filter_var($upper, FILTER_VALIDATE_INT)) { + return Schema::TYPE_INT_4_RANGE; + } + + if (filter_var($lower, FILTER_VALIDATE_FLOAT) && filter_var($upper, FILTER_VALIDATE_FLOAT)) { + return Schema::TYPE_NUM_RANGE; + } + } + + $value = $lower ?? $upper; + + if (filter_var($value, FILTER_VALIDATE_INT)) { + return Schema::TYPE_INT_4_RANGE; + } + + + if (filter_var($value, FILTER_VALIDATE_FLOAT)) { + return Schema::TYPE_NUM_RANGE; + } + + if (DateTime::createFromFormat('Y-m-d', $value)) { + return Schema::TYPE_DATE_RANGE; + } + + if (DateTime::createFromFormat('Y-m-d H:i:s', $value)) { + return Schema::TYPE_TS_RANGE; + } + + if (DateTime::createFromFormat('Y-m-d H:i:sP', $value)) { + return Schema::TYPE_TS_TZ_RANGE; + } + + return null; + } +} diff --git a/src/Schema.php b/src/Schema.php index 5ea02bf22..17416b136 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -89,6 +89,12 @@ final class Schema extends AbstractSchema implements ConstraintFinderInterface use ViewFinderTrait; public const TYPE_JSONB = 'jsonb'; + public const TYPE_INT_4_RANGE = 'int4range'; + public const TYPE_INT_8_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'; /** * @var array mapping from physical column types (keys) to abstract column types (values). @@ -159,6 +165,12 @@ final class Schema extends AbstractSchema implements ConstraintFinderInterface 'json' => self::TYPE_JSON, 'jsonb' => self::TYPE_JSON, 'xml' => self::TYPE_STRING, + 'int4range' => self::TYPE_INT_4_RANGE, + 'int8range' => self::TYPE_INT_8_RANGE, + 'numrange' => self::TYPE_NUM_RANGE, + 'tsrange' => self::TYPE_TS_RANGE, + 'tstzrange' => self::TYPE_TS_TZ_RANGE, + 'daterange' => self::TYPE_DATE_RANGE, ]; /** diff --git a/tests/Fixture/postgres.sql b/tests/Fixture/postgres.sql index 3be9e7051..eeb20d6e7 100644 --- a/tests/Fixture/postgres.sql +++ b/tests/Fixture/postgres.sql @@ -35,6 +35,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 "ranges"; DROP SCHEMA IF EXISTS "schema1" CASCADE; DROP SCHEMA IF EXISTS "schema2" CASCADE; @@ -424,3 +425,14 @@ CREATE TABLE "T_upsert_1" ( "a" INT NOT NULL PRIMARY KEY ); + +CREATE TABLE "ranges" +( + "id" SERIAL NOT NULL PRIMARY KEY, + "int_range" int4range, + "bigint_range" int8range, + "num_range" numrange, + "ts_range" tsrange, + "ts_tz_range" tstzrange, + "date_range" daterange +); diff --git a/tests/RangeParserTest.php b/tests/RangeParserTest.php new file mode 100644 index 000000000..558005fb6 --- /dev/null +++ b/tests/RangeParserTest.php @@ -0,0 +1,176 @@ + 'int_range', + Schema::TYPE_INT_8_RANGE => 'bigint_range', + Schema::TYPE_NUM_RANGE => 'num_range', + Schema::TYPE_DATE_RANGE => 'date_range', + Schema::TYPE_TS_RANGE => 'ts_range', + Schema::TYPE_TS_TZ_RANGE => 'ts_tz_range', + ]; + + /** + * @param string $type + * @param string $field + * @param string $brackets + * @param array $inserted + * + * @return mixed[] + */ + public function insertAndGetResult(string $field, string $type, string $brackets, array $inserted): array + { + $db = $this->getConnection(); + $db->createCommand()->delete(self::TABLE)->execute(); + + $db->createCommand()->insert( + self::TABLE, + [ + $field => new Expression( + $type . "(:min, :max, '" . $brackets . "')", + [':min' => $inserted[0], ':max' => $inserted[1]] + ), + ] + )->execute(); + + return (new Query($db))->select([$field])->from(self::TABLE)->one(); + } + + /** + * @return array + */ + public function rangeNumberProvider(): array + { + return [ + // ::int4range + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '[]', 'inserted' => [1, 10], 'expected' => [1, 10], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '[]', 'inserted' => [null, 10], 'expected' => [null, 10], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '[]', 'inserted' => [10, null], 'expected' => [10, null], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '[]', 'inserted' => [-100, 10], 'expected' => [-100, 10], ], + + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '[)', 'inserted' => [1, 10], 'expected' => [1, 9], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '[)', 'inserted' => [null, 10], 'expected' => [null, 9], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '[)', 'inserted' => [10, null], 'expected' => [10, null], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '[)', 'inserted' => [-100, 10], 'expected' => [-100, 9], ], + + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '(]', 'inserted' => [1, 10], 'expected' => [2, 10], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '(]', 'inserted' => [null, 10], 'expected' => [null, 10], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '(]', 'inserted' => [10, null], 'expected' => [11, null], ], + [ Schema::TYPE_INT_4_RANGE, 'brackets' => '(]', 'inserted' => [-100, 10], 'expected' => [-99, 10], ], + + // ::int8range + [ Schema::TYPE_INT_8_RANGE, '[]', ['2147483647', '2147483650'], ['2147483647', '2147483650'], ], + [ Schema::TYPE_INT_8_RANGE, '[]', ['4147483647', null], ['4147483647', null], ], + [ Schema::TYPE_INT_8_RANGE, '[]', [null, '2147483650'], [null, '2147483650'], ], + [ Schema::TYPE_INT_8_RANGE, '[]', ['-2147483650', '2147483650'], ['-2147483650', '2147483650'], ], + + [ Schema::TYPE_INT_8_RANGE, '[)', ['2147483647', '2147483650'], ['2147483647', '2147483649'], ], + [ Schema::TYPE_INT_8_RANGE, '[)', ['4147483647', null], ['4147483647', null], ], + [ Schema::TYPE_INT_8_RANGE, '[)', [null, '2147483650'], [null, '2147483649'], ], + [ Schema::TYPE_INT_8_RANGE, '[)', ['-2147483650', '2147483650'], ['-2147483650', '2147483649'], ], + + [ Schema::TYPE_INT_8_RANGE, '(]', ['2147483647', '2147483650'], ['2147483648', '2147483650'], ], + [ Schema::TYPE_INT_8_RANGE, '(]', ['4147483647', null], ['4147483648', null], ], + [ Schema::TYPE_INT_8_RANGE, '(]', [null, '2147483650'], [null, '2147483650'], ], + [ Schema::TYPE_INT_8_RANGE, '(]', ['-2147483650', '2147483650'], ['-2147483649', '2147483650'], ], + + // ::numrange + [ Schema::TYPE_NUM_RANGE, '[]', [10.5, 20.7], [10.5, 20.7], ], + [ Schema::TYPE_NUM_RANGE, '[]', [30.7, null], [30.7, null], ], + [ Schema::TYPE_NUM_RANGE, '[]', [null, 20.7], [null, 20.7], ], + [ Schema::TYPE_NUM_RANGE, '[]', [-13.2, 20.7], [-13.2, 20.7], ], + + [ Schema::TYPE_NUM_RANGE, '[)', [10.5, 20.7], [10.5, 20.7], ], + [ Schema::TYPE_NUM_RANGE, '(]', [10.5, 20.7], [10.5, 20.7], ], + [ Schema::TYPE_NUM_RANGE, '()', [10.5, 20.7], [10.5, 20.7], ], + ]; + } + + /** + * @dataProvider rangeNumberProvider + */ + public function testNumberRanges(string $type, string $brackets, array $inserted, array $expected): void + { + $field = self::FIELDS[$type]; + $row = $this->insertAndGetResult($field, $type, $brackets, $inserted); + + $parser = new RangeParser($type); + $result = $parser->parse($row[$field]); + + $this->assertEquals($result, $expected); + } + + /** + * @return array[] + */ + public function rangeDateProvider(): array + { + return [ + // ::daterange + [ Schema::TYPE_DATE_RANGE, '[]', ['2020-12-01', '2021-01-01'], ['2020-12-01', '2021-01-01'], 'Y-m-d', ], + [ Schema::TYPE_DATE_RANGE, '[]', ['2020-12-01', null], ['2020-12-01', null], 'Y-m-d', ], + [ Schema::TYPE_DATE_RANGE, '[]', [null, '2020-12-01'], [null, '2020-12-01'], 'Y-m-d', ], + + [ Schema::TYPE_DATE_RANGE, '[)', ['2020-12-01', '2021-01-01'], ['2020-12-01', '2020-12-31'], 'Y-m-d', ], + [ Schema::TYPE_DATE_RANGE, '[)', ['2020-12-01', null], ['2020-12-01', null], 'Y-m-d', ], + [ Schema::TYPE_DATE_RANGE, '[)', [null, '2020-12-01'], [null, '2020-11-30'], 'Y-m-d',], + + [ Schema::TYPE_DATE_RANGE, '(]', ['2020-12-01', '2021-01-01'], ['2020-12-02', '2021-01-01'], 'Y-m-d', ], + [ Schema::TYPE_DATE_RANGE, '(]', ['2020-12-01', null], ['2020-12-02', null], 'Y-m-d', ], + [ Schema::TYPE_DATE_RANGE, '(]', [null, '2020-12-01'], [null, '2020-12-01'], 'Y-m-d', ], + + // ::tsrange + [ Schema::TYPE_TS_RANGE, '[]', ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], 'Y-m-d H:i:s', ], + [ Schema::TYPE_TS_RANGE, '[]', ['2017-10-20 10:10:00', null], ['2017-10-20 10:10:00', null], 'Y-m-d H:i:s', ], + [ Schema::TYPE_TS_RANGE, '[]', [null, '2018-10-20 15:10:00'], [null, '2018-10-20 15:10:00'], 'Y-m-d H:i:s', ], + + [ Schema::TYPE_TS_RANGE, '[)', ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], 'Y-m-d H:i:s', ], + [ Schema::TYPE_TS_RANGE, '(]', ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], 'Y-m-d H:i:s', ], + [ Schema::TYPE_TS_RANGE, '()', ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], 'Y-m-d H:i:s', ], + [ Schema::TYPE_TS_RANGE, '()', ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], ['2017-10-20 10:10:00', '2018-10-20 15:10:00'], 'Y-m-d H:i:s', ], + + // ::tstzrange + [ Schema::TYPE_TS_TZ_RANGE, '[]', ['2018-10-20 10:10:00+00:00', '2019-10-20 15:10:00+00:00'], ['2018-10-20 10:10:00+00:00', '2019-10-20 15:10:00+00:00'], 'Y-m-d H:i:sP', ], + [ Schema::TYPE_TS_TZ_RANGE, '[]', ['2018-10-20 10:10:00+00:00', null], ['2018-10-20 10:10:00+00:00', null], 'Y-m-d H:i:sP', ], + [ Schema::TYPE_TS_TZ_RANGE, '[]', [null, '2019-10-20 15:10:00+00:00'], [null, '2019-10-20 15:10:00+00:00'], 'Y-m-d H:i:sP', ], + + [ Schema::TYPE_TS_TZ_RANGE, '(]', ['2018-10-20 10:10:00+00:00', '2019-10-20 15:10:00+00:00'], ['2018-10-20 10:10:00+00:00', '2019-10-20 15:10:00+00:00'], 'Y-m-d H:i:sP', ], + [ Schema::TYPE_TS_TZ_RANGE, '[)', ['2018-10-20 10:10:00+00:00', '2019-10-20 15:10:00+00:00'], ['2018-10-20 10:10:00+00:00', '2019-10-20 15:10:00+00:00'], 'Y-m-d H:i:sP', ], + [ Schema::TYPE_TS_TZ_RANGE, '()', ['2018-10-20 10:10:00+00:00', '2019-10-20 15:10:00+00:00'], ['2018-10-20 10:10:00+00:00', '2019-10-20 15:10:00+00:00'], 'Y-m-d H:i:sP', ], + ]; + } + + /** + * @dataProvider rangeDateProvider + */ + public function testDateRanges(string $type, string $brackets, array $inserted, array $expected, string $format): void + { + $field = self::FIELDS[$type]; + $row = $this->insertAndGetResult($field, $type, $brackets, $inserted); + + $parser = new RangeParser($type); + $result = $parser->parse($row[$field]); + + $this->assertIsString($row[$field]); + $this->assertEquals($expected, [ + $result[0] instanceof DateTime ? $result[0]->format($format) : null, + $result[1] instanceof DateTime ? $result[1]->format($format) : null, + ]); + } +}