10000 [TypeInfo] Add `IntRangeType` to hold specific type details for `int` builtin types by DerManoMann · Pull Request #59676 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[TypeInfo] Add IntRangeType to hold specific type details for int builtin types #59676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
8000 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
Next Next commit
Add IntRangeType and ExplicitStringType
  • Loading branch information
DerManoMann committed Feb 22, 2025
commit 16d885de9fd9fc7e62fe9a56d26698014f334369
1 change: 1 addition & 0 deletions src/Symfony/Component/TypeInfo/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
7.3
---

* Add `IntRangeType` and `ExplicitStringType` classes to capture more specific type detals for `int` and `string` builtin types
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Add `IntRangeType` and `ExplicitStringType` classes to capture more specific type detals for `int` and `string` builtin types
* Add `IntRangeType` and `ExplicitStringType` classes to hold specific type details for `int` and `string` builtin types

* Add `Type::accepts()` method
* Add `TypeFactoryTrait::fromValue()` method
* Deprecate constructing a `CollectionType` instance as a list that is not an array
Expand Down
60 changes: 60 additions & 0 deletions src/Symfony/Component/TypeInfo/Tests/Type/IntRangeTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\TypeInfo\Tests\Type;

use PHPUnit\Framework\TestCase;
use Symfony\Component\TypeInfo\Type\BuiltinType;
use Symfony\Component\TypeInfo\Type\IntRangeType;
use Symfony\Component\TypeInfo\TypeIdentifier;

class IntRangeTypeTest extends TestCase
{
public function testToString()
{
$this->assertSame('non-negative-int', (string) new IntRangeType(0, \PHP_INT_MAX));
$this->assertSame('non-positive-int', (string) new IntRangeType(\PHP_INT_MIN, 0));
$this->assertSame('positive-int', (string) new IntRangeType(1, \PHP_INT_MAX));
$this->assertSame('negative-int', (string) new IntRangeType(\PHP_INT_MIN, -1));
$this->assertSame('int<-3, 5>', (string) new IntRangeType(-3, 5));
$this->assertSame('int<min, max>', (string) new IntRangeType(\PHP_INT_MIN, \PHP_INT_MAX));
$this->assertSame('int<min, 5>', (string) new IntRangeType(\PHP_INT_MIN, 5));
}

public function testIsIdentifiedBy()
{
$this->assertFalse((new IntRangeType(0, 5))->isIdentifiedBy(TypeIdentifier::ARRAY));
$this->assertTrue((new BuiltinType(TypeIdentifier::INT))->isIdentifiedBy(TypeIdentifier::INT));

$this->assertFalse((new IntRangeType(0, 5))->isIdentifiedBy('array'));
$this->assertTrue((new IntRangeType(0, 5))->isIdentifiedBy('int'));

$this->assertTrue((new IntRangeType(0, 5))->isIdentifiedBy('string', 'int'));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this test is needed (we are testing that IntRangeType is extending BuiltinType<int> here)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair enough, removing


public function testIsNullable()
{
$this->assertFalse((new IntRangeType(0, 5))->isNullable());
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here


public function testAccepts()
{
$this->assertFalse((new IntRangeType(0, 5))->accepts('string'));
$this->assertFalse((new IntRangeType(0, 5))->accepts([]));

$this->assertFalse((new IntRangeType(-1, 5))->accepts(-3));
$this->assertTrue((new IntRangeType(-1, 5))->accepts(0));
$this->assertTrue((new IntRangeType(-1, 5))->accepts(2));
$this->assertFalse((new IntRangeType(-1, 5))->accepts(6));

$this->assertFalse((new IntRangeType(\PHP_INT_MIN, \PHP_INT_MAX, false))->accepts(0));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,26 +98,17 @@ public static function resolveDataProvider(): iterable
yield [Type::false(), 'false'];
yield [Type::int(), 'int'];
yield [Type::int(), 'integer'];
yield [Type::int(), 'positive-int'];
yield [Type::int(), 'negative-int'];
yield [Type::int(), 'non-positive-int'];
yield [Type::int(), 'non-negative-int'];
yield [Type::int(), 'non-zero-int'];
yield [Type::intRange(1, \PHP_INT_MAX), 'positive-int'];
yield [Type::intRange(\PHP_INT_MIN, -1), 'negative-int'];
yield [Type::intRange(\PHP_INT_MIN, 0), 'non-positive-int'];
yield [Type::intRange(0, \PHP_INT_MAX), 'non-negative-int'];
yield [Type::intRange(\PHP_INT_MIN, \PHP_INT_MAX, false), 'non-zero-int'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can be moved to // int range section

yield [Type::float(), 'float'];
yield [Type::float(), 'double'];
yield [Type::string(), 'string'];
yield [Type::string(), 'class-string'];
yield [Type::string(), 'trait-string'];
yield [Type::string(), 'interface-string'];
yield [Type::string(), 'callable-string'];
yield [Type::string(), 'n 8000 umeric-string'];
yield [Type::string(), 'lowercase-string'];
yield [Type::string(), 'non-empty-lowercase-string'];
yield [Type::string(), 'non-empty-string'];
yield [Type::string(), 'non-falsy-string'];
yield [Type::string(), 'truthy-string'];
yield [Type::string(), 'literal-string'];
yield [Type::string(), 'html-escaped-string'];
yield [Type::explicitString('class-string'), 'class-string'];
yield [Type::explicitString('literal-string'), 'literal-string'];
yield [Type::explicitString('html-escaped-string'), 'html-escaped-string'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have a section for these as well

yield [Type::resource(), 'resource'];
yield [Type::object(), 'object'];
yield [Type::callable(), 'callable'];
Expand Down Expand Up @@ -152,7 +143,8 @@ public static function resolveDataProvider(): iterable
// generic
yield [Type::generic(Type::object(\DateTime::class), Type::string(), Type::bool()), \DateTime::class.'<string, bool>'];
yield [Type::generic(Type::object(\DateTime::class), Type::generic(Type::object(\Stringable::class), Type::bool())), \sprintf('%s<%s<bool>>', \DateTime::class, \Stringable::class)];
yield [Type::int(), 'int<0, 100>'];
yield [Type::intRange(0, 100), 'int<0, 100>'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
yield [Type::intRange(0, 100), 'int<0, 100>'];
// int range
yield [Type::intRange(0, 100), 'int<0, 100>'];

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be put under // int range section

yield [Type::intRange(\PHP_INT_MIN, \PHP_INT_MAX), 'int<min, max>'];

// union
yield [Type::union(Type::int(), Type::string()), 'int|string'];
Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/Component/TypeInfo/Type/BuiltinType.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
*
* @template T of TypeIdentifier
*/
final class BuiltinType extends Type
class BuiltinType extends Type
{
/**
* @param T $typeIdentifier
Expand Down
34 changes: 34 additions & 0 deletions src/Symfony/Component/TypeInfo/Type/ExplicitStringType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\TypeInfo\Type;

use Symfony\Component\TypeInfo\TypeIdentifier;

/**
* Explicit string type.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Explicit string type.
* Represents a string with explicit constraints.

*
* @author Martin Rademacher <mano@radebatz.net>
*
* @extends BuiltinType<TypeIdentifier::STRING>
*/
class ExplicitStringType extends BuiltinType
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class ExplicitStringType extends BuiltinType
final class ExplicitStringType extends BuiltinType

{
public function __construct(private string $explicitType)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I do think we need an enum here:

Suggested change
public function __construct(private string $explicitType)
public function __construct(private StringConstraint $constraint)

And then maybe ConstrainedStringType is better?

By doing that, we'll be able to implement the accepts method as well.

{
parent::__construct(TypeIdentifier::STRING);
}

public function getExplicitType(): string
{
return $this->explicitType;
}
}
73 changes: 73 additions & 0 deletions src/Symfony/Component/TypeInfo/Type/IntRangeType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\TypeInfo\Type;

use Symfony\Component\TypeInfo\TypeIdentifier;

/**
* Int range type.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Int range type.
* Represents a range of integer values.

*
* @author Martin Rademacher <mano@radebatz.net>
*
* @extends BuiltinType<TypeIdentifier::INT>
*/
class IntRangeType extends BuiltinType
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class IntRangeType extends BuiltinType
final class IntRangeType extends BuiltinType

{
public function __construct(
private int $from = \PHP_INT_MIN,
private int $to = \PHP_INT_MAX,
private bool $zeroIncluded = true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This property is problematic to me, as zero shouldn't be a "special value".

To handle the non-zero-int case, we might instead lead to the creation of a Type::union(Type::intRange(to: -1), Type::intRange(from: 1)).

And the StringTypeResolver could handle this seamlessly 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good solution. Bugged me too that it is ambiguous and creates extra overhead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might break the symetrie of serialize/deserialize, but I guess that is a fair trade-off.

) {
parent::__construct(TypeIdentifier::INT);
}

public function getFrom(): int
{
return $this->from;
}

public function getTo(): int
{
return $this->to;
}

public function isZeroIncluded(): bool
{
return $this->zeroIncluded;
}

public function accepts(mixed $value): bool
{
return \is_int($value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return \is_int($value)
return parent::accepts($value)

&& $this->from <= $value && $value <= $this->to
&& (0 !== $value || $this->zeroIncluded);
}

public function __toString(): string
{
$min = \PHP_INT_MIN === $this->from ? 'min' : $this->from;
$max = \PHP_INT_MAX === $this->to ? 'max' : $this->to;

$template = 'int<%s, %s>';
if (\is_string($min) && \is_string($max)) {
return $this->zeroIncluded ? \sprintf($template, $min, $max) : 'non-zero-int';
}

if (\in_array($min, [0, 1], true) && 'max' === $max) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd have removed these conditions to only have a int<foo, bar> canonical string representation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be more in line with what we do with non-zero-int. Also, come to think of it, there is no telling where we started anyway...

return 0 === $min ? 'non-negative-int' : 'positive-int';
} elseif ('min' === $min && \in_array($max, [-1, 0])) {
return 0 === $max ? 'non-positive-int' : 'negative-int';
}

return \sprintf($template, $min, $max);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$template = 'int<%s, %s>';
if (\is_string($min) && \is_string($max)) {
return $this->zeroIncluded ? \sprintf($template, $min, $max) : 'non-zero-int';
}
if (\in_array($min, [0, 1], true) && 'max' === $max) {
return 0 === $min ? 'non-negative-int' : 'positive-int';
} elseif ('min' === $min && \in_array($max, [-1, 0])) {
return 0 === $max ? 'non-positive-int' : 'negative-int';
}
return \sprintf($template, $min, $max);
return \sprintf('int<%s, %s>', $min, $max);

I'd always kept the same canonical format (it is only possible if we drop the "excluding zero" stuff).

}
}
12 changes: 12 additions & 0 deletions src/Symfony/Component/TypeInfo/TypeFactoryTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
use Symfony\Component\TypeInfo\Type\BuiltinType;
use Symfony\Component\TypeInfo\Type\CollectionType;
use Symfony\Component\TypeInfo\Type\EnumType;
use Symfony\Component\TypeInfo\Type\ExplicitStringType;
use Symfony\Component\TypeInfo\Type\GenericType;
use Symfony\Component\TypeInfo\Type\IntersectionType;
use Symfony\Component\TypeInfo\Type\IntRangeType;
use Symfony\Component\TypeInfo\Type\NullableType;
use Symfony\Component\TypeInfo\Type\ObjectType;
use Symfony\Component\TypeInfo\Type\TemplateType;
Expand Down Expand Up @@ -54,6 +56,11 @@ public static function int(): BuiltinType
return self::builtin(TypeIdentifier::INT);
}

public static function intRange(int $from, int $to, bool $zeroIncluded = true): IntRangeType
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public static function intRange(int $from, int $to, bool $zeroIncluded = true): IntRangeType
public static function intRange(int $from = \PHP_INT_MIN, int $to = \PHP_INT_MAX): IntRangeType

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, but keeing $zeroIncluded, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of #59676 (comment), I think we can remove it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good point. Sorry, I missed that one.

{
return new IntRangeType($from, $to, $zeroIncluded);
}

/**
* @return BuiltinType<TypeIdentifier::FLOAT>
*/
Expand All @@ -70,6 +77,11 @@ public static function string(): BuiltinType
return self::builtin(TypeIdentifier::STRING);
}

public static function explicitString(string $explicitType): ExplicitStringType
{
return new ExplicitStringType($explicitType);
}

/**
* @return BuiltinType<TypeIdentifier::BOOL>
*/
Expand Down
33 changes: 28 additions & 5 deletions src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,14 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ
'bool', 'boolean' => Type::bool(),
'true' => Type::true(),
'false' => Type::false(),
'int', 'integer', 'positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'non-zero-int' => Type::int(),
'int', 'integer' => Type::int(),
'positive-int' => Type::intRange(1, \PHP_INT_MAX),
'negative-int' => Type::intRange(\PHP_INT_MIN, -1),
'non-positive-int' => Type::intRange(\PHP_INT_MIN, 0),
'non-negative-int' => Type::intRange(0, \PHP_INT_MAX),
'non-zero-int' => Type::intRange(\PHP_INT_MIN, \PHP_INT_MAX, false),
'float', 'double' => Type::float(),
'string',
'string' => Type::string(),
'class-string',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'll need to handle class-string<Foo>, and interface-string<Bar> in a specific way.
IMHO, we should split the work in 2 PRs, on for the integers stuff, and on other for the strings stuff.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was wondering about that. Seems wrong to leave things half finished.
Happy to create a second PR. Not sure what the pattern is here - wait until this is merged, or base thge second off this branch?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I meant to move all the string related stuff (ie: ExplicitString) in another PR. By doing that, you won't have to base the second PR on the first.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. Getting late here...

'trait-string',
'interface-string',
Expand All @@ -149,7 +154,7 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ
'non-falsy-string',
'truthy-string',
'literal-string',
'html-escaped-string' => Type::string(),
'html-escaped-string' => Type::explicitString($node->name),
'resource' => Type::resource(),
'object' => Type::object(),
'callable' => Type::callable(),
Expand Down Expand Up @@ -184,9 +189,27 @@ private function getTypeFromNode(TypeNode $node, ?TypeContext $typeContext): Typ
if ($node instanceof GenericTypeNode) {
$type = $this->getTypeFromNode($node->type, $typeContext);

// handle integer ranges as simple integers
// handle integer ranges
if ($type->isIdentifiedBy(TypeIdentifier::INT)) {
return $type;
$getValueFromNode = function (TypeNode $node) {
if ($node instanceof IdentifierTypeNode) {
return match ($node->name) {
'min' => \PHP_INT_MIN,
'max' => \PHP_INT_MAX,
default => throw new \DomainException(\sprintf('Invalid int range value "%s".', $node->name)),
};
}

if ($node->constExpr instanceof ConstExprIntegerNode) {
return (int) $node->constExpr->value;
}

throw new \DomainException(\sprintf('Invalid int range expression "%s".', \get_class($node->constExpr)));
};

$range = array_map(fn (TypeNode $t): int => $getValueFromNode($t), $node->genericTypes);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$range = array_map(fn (TypeNode $t): int => $getValueFromNode($t), $node->genericTypes);
$boundaries = array_map(static fn (TypeNode $t): int => $getBoundaryFromNode($t), $node->genericTypes);


return Type::intRange(...$range);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Type::intRange(...$range);
return Type::intRange($boudaries[0], $boundaries[1]);

}

$variableTypes = array_map(fn (TypeNode $t): Type => $this->getTypeFromNode($t, $typeContext), $node->genericTypes);
Expand Down
0