Skip to content

Commit

Permalink
Better abstraction around how union and intersection types are valida…
Browse files Browse the repository at this point in the history
…ted internally

Signed-off-by: Marco Pivetta <ocramius@gmail.com>
  • Loading branch information
Ocramius committed Dec 8, 2022
1 parent 56d7de0 commit ffbd004
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 38 deletions.
52 changes: 26 additions & 26 deletions src/Generator/TypeGenerator/AtomicType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use Laminas\Code\Generator\Exception\InvalidArgumentException;

use function array_filter;
use function array_key_exists;
use function assert;
use function implode;
Expand All @@ -22,7 +21,7 @@
*
* @psalm-immutable
*/
final class AtomicType implements TypeInterface
final class AtomicType
{
/**
* Built-in type sorting, ascending.
Expand Down Expand Up @@ -129,17 +128,20 @@ public function fullyQualifiedName(): string
}

/** @return non-empty-string */
public function __toString(): string
public function toString(): string
{
return $this->type;
}

/**
* @psalm-param non-empty-array<self> $others
* @throws InvalidArgumentException
*/
public function assertCanUnionWith(array $others): void
/** @throws InvalidArgumentException */
public function assertCanUnionWith(self|IntersectionType $other): void
{
if ($other instanceof IntersectionType) {
$other->assertCanUnionWith($this);

return;
}

if (
'mixed' === $this->type
|| 'void' === $this->type
Expand All @@ -151,25 +153,23 @@ public function assertCanUnionWith(array $others): void
));
}

foreach ($others as $other) {
if ($other->type === $this->type) {
throw new InvalidArgumentException(sprintf(
'Type "%s" cannot be composed in a union with the same type "%s"',
$this->type,
$other->type
));
}
if ($other->type === $this->type) {
throw new InvalidArgumentException(sprintf(
'Type "%s" cannot be composed in a union with the same type "%s"',
$this->type,
$other->type
));
}

if (
('true' === $other->type && 'false' === $this->type) ||
('false' === $other->type && 'true' === $this->type)
) {
throw new InvalidArgumentException(sprintf(
'Type "%s" cannot be composed in a union with type "%s"',
$this->type,
$other->type
));
}
if (
('true' === $other->type && 'false' === $this->type) ||
('false' === $other->type && 'true' === $this->type)
) {
throw new InvalidArgumentException(sprintf(
'Type "%s" cannot be composed in a union with type "%s"',
$this->type,
$other->type
));
}
}

Expand Down
25 changes: 25 additions & 0 deletions src/Generator/TypeGenerator/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use function array_flip;
use function array_map;
use function implode;
use function sprintf;
use function str_contains;
use function usort;

/**
Expand Down Expand Up @@ -60,4 +62,27 @@ public function fullyQualifiedName(): string
array_map(static fn(AtomicType $type): string => $type->fullyQualifiedName(), $this->types)
);
}

/** @throws InvalidArgumentException */
public function assertCanUnionWith(AtomicType|self $other): void
{
if ($other instanceof AtomicType) {
foreach ($this->types as $type) {
$type->assertCanUnionWith($other);
}

return;
}

$thisString = $this->toString();
$otherString = $other->toString();

if (str_contains($thisString, $otherString) || str_contains($otherString, $thisString)) {
throw new InvalidArgumentException(sprintf(
'Types "%s" and "%s" cannot be intersected, as they include each other',
$thisString,
$otherString
));
}
}
}
10 changes: 9 additions & 1 deletion src/Generator/TypeGenerator/UnionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Laminas\Code\Generator\TypeGenerator;

use function array_diff_key;
use function array_flip;
use function implode;
use function usort;

Expand All @@ -12,7 +14,7 @@
*/
final class UnionType
{
/** @var non-empty-list<AtomicType|IntersectionType> $types at least 2 values always present */
/** @var non-empty-list<AtomicType|IntersectionType> $types sorted, at least 2 values always present */
private readonly array $types;

/** @param non-empty-list<AtomicType|IntersectionType> $types at least 2 values needed */
Expand All @@ -29,6 +31,12 @@ public function __construct(array $types)
]
);

foreach ($types as $index => $type) {
foreach (array_diff_key($types, array_flip([$index])) as $otherType) {
$type->assertCanUnionWith($otherType);
}
}

$this->types = $types;
}

Expand Down
98 changes: 87 additions & 11 deletions test/Generator/TypeGenerator/UnionTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace LaminasTest\Code\Generator\TypeGenerator;

use Laminas\Code\Generator\Exception\InvalidArgumentException;
use Laminas\Code\Generator\TypeGenerator\AtomicType;
use Laminas\Code\Generator\TypeGenerator\IntersectionType;
use Laminas\Code\Generator\TypeGenerator\UnionType;
Expand Down Expand Up @@ -32,42 +33,42 @@ public function testTypeSorting(array $types, string $expected): void
public static function sortingExamples(): array
{
return [
'class types are sorted by name' => [
'class types are sorted by name' => [
[
AtomicType::fromString('\C'),
AtomicType::fromString('A'),
AtomicType::fromString('\B'),
AtomicType::fromString('\D'),
],
'\A|\B|\C|\D'
'\A|\B|\C|\D',
],
'built-in types are moved to the end' => [
'built-in types are moved to the end' => [
[
AtomicType::fromString('iterable'),
AtomicType::fromString('myIterator1'),
AtomicType::fromString('myIterator2'),
],
'\myIterator1|\myIterator2|iterable'
'\myIterator1|\myIterator2|iterable',
],
'built-in types are kept at the end' => [
'built-in types are kept at the end' => [
[
AtomicType::fromString('myIterator2'),
AtomicType::fromString('myIterator1'),
AtomicType::fromString('iterable'),
],
'\myIterator1|\myIterator2|iterable'
'\myIterator1|\myIterator2|iterable',
],
'built-in types are sorted by priority' => [
'built-in types are sorted by priority' => [
[
AtomicType::fromString('float'),
AtomicType::fromString('int'),
AtomicType::fromString('string'),
AtomicType::fromString('null'),
AtomicType::fromString('bool'),
],
'bool|int|float|string|null'
'bool|int|float|string|null',
],
'intersection types are moved upfront' => [
'intersection types are moved upfront' => [
[
AtomicType::fromString('A'),
AtomicType::fromString('D'),
Expand All @@ -76,7 +77,7 @@ public static function sortingExamples(): array
AtomicType::fromString('C'),
]),
],
'(\B&\C)|\A|\D'
'(\B&\C)|\A|\D',
],
'intersection types are sorted by their own relative order' => [
[
Expand All @@ -89,7 +90,82 @@ public static function sortingExamples(): array
AtomicType::fromString('D'),
]),
],
'(\A&\D)|(\B&\C)'
'(\A&\D)|(\B&\C)',
],
];
}

/**
* @dataProvider invalidUnionsExamples
*
* @param non-empty-list<AtomicType|IntersectionType> $types
*/
public function testWillRejectInvalidUnions(array $types): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(' cannot be ');

new UnionType($types);
}

/** @return non-empty-array<non-empty-string, array{non-empty-list<AtomicType>}> */
public static function invalidUnionsExamples(): array
{
return [
'same intersection type makes no sense' => [
[
new IntersectionType([
AtomicType::fromString('A'),
AtomicType::fromString('B'),
]),
new IntersectionType([
AtomicType::fromString('A'),
AtomicType::fromString('B'),
]),
],
],
'same intersection type, even if in different order, is invalid' => [
[
new IntersectionType([
AtomicType::fromString('A'),
AtomicType::fromString('B'),
]),
new IntersectionType([
AtomicType::fromString('B'),
AtomicType::fromString('A'),
]),
],
],
'intersection type with atomic type contained in it makes no sense' => [
[
new IntersectionType([
AtomicType::fromString('A'),
AtomicType::fromString('B'),
]),
AtomicType::fromString('A'),
],
],
'same atomic type makes no sense, even with different namespace qualifier' => [
[
AtomicType::fromString('A'),
AtomicType::fromString('\A'),
],
],
'duplicate type in long chain of types' => [
[
AtomicType::fromString('A'),
AtomicType::fromString('B'),
AtomicType::fromString('C'),
AtomicType::fromString('D'),
AtomicType::fromString('A'),
AtomicType::fromString('E'),
],
],
'mixed cannot union with other types (redundant)' => [
[
AtomicType::fromString('mixed'),
AtomicType::fromString('bool'),
],
],
];
}
Expand Down

0 comments on commit ffbd004

Please sign in to comment.