Skip to content
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

feat: allow mapping to array-key type #506

Merged
merged 1 commit into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/Type/CombiningType.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface CombiningType extends CompositeType
public function isMatchedBy(Type $other): bool;

/**
* @return Type[]
* @return non-empty-list<Type>
*/
public function types(): iterable;
public function types(): array;
}
3 changes: 2 additions & 1 deletion src/Type/Parser/Lexer/Token/ClassNameToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use function array_keys;
use function array_map;
use function array_shift;
use function array_values;
use function count;
use function explode;

Expand Down Expand Up @@ -101,7 +102,7 @@ private function classConstant(TokenStream $stream): ?Type
$cases = array_map(static fn ($value) => ValueTypeFactory::from($value), $cases);

if (count($cases) > 1) {
return new UnionType(...$cases);
return new UnionType(...array_values($cases));
}

return reset($cases);
Expand Down
56 changes: 49 additions & 7 deletions src/Type/Types/ArrayKeyType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,56 @@

namespace CuyZ\Valinor\Type\Types;

use CuyZ\Valinor\Type\CombiningType;
use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage;
use CuyZ\Valinor\Mapper\Tree\Message\MessageBuilder;
use CuyZ\Valinor\Type\IntegerType;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\InvalidArrayKey;
use CuyZ\Valinor\Type\ScalarType;
use CuyZ\Valinor\Type\StringType;
use CuyZ\Valinor\Type\Type;
use LogicException;

use function is_int;

/** @internal */
final class ArrayKeyType implements Type
final class ArrayKeyType implements ScalarType
{
private static self $default;

private static self $integer;

private static self $string;

/** @var array<Type> */
/** @var non-empty-list<IntegerType|StringType> */
private array $types;

private string $signature;

private function __construct(Type $type)
{
$this->signature = $type->toString();
$this->types = $type instanceof CombiningType
$types = $type instanceof UnionType
? [...$type->types()]
: [$type];

foreach ($this->types as $subType) {
foreach ($types as $subType) {
if (! $subType instanceof IntegerType && ! $subType instanceof StringType) {
throw new InvalidArrayKey($subType);
}
}

/** @var non-empty-list<IntegerType|StringType> $types */
$this->types = $types;
$this->signature = $type->toString();
}

public static function default(): self
{
return self::$default ??= new self(new UnionType(NativeIntegerType::get(), NativeStringType::get()));
if (!isset(self::$default)) {
self::$default = new self(new UnionType(NativeIntegerType::get(), NativeStringType::get()));
self::$default->signature = 'array-key';
}

return self::$default;
}

public static function integer(): self
Expand Down Expand Up @@ -86,6 +97,10 @@ public function matches(Type $other): bool
return true;
}

if ($other instanceof UnionType) {
return $this->isMatchedBy($other);
}

if (! $other instanceof self) {
return false;
}
Expand Down Expand Up @@ -114,6 +129,33 @@ public function isMatchedBy(Type $other): bool
return false;
}

public function canCast(mixed $value): bool
{
foreach ($this->types as $type) {
if ($type->canCast($value)) {
return true;
}
}

return false;
}

public function cast(mixed $value): string|int
{
foreach ($this->types as $type) {
if ($type->canCast($value)) {
return $type->cast($value);
}
}

throw new LogicException();
}

public function errorMessage(): ErrorMessage
{
return MessageBuilder::newError('Value {source_value} is not a valid array key.')->build();
}

public function toString(): string
{
return $this->signature;
Expand Down
11 changes: 6 additions & 5 deletions src/Type/Types/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,21 @@
use CuyZ\Valinor\Type\CompositeType;
use CuyZ\Valinor\Type\Type;

use function array_values;
use function implode;

/** @internal */
final class IntersectionType implements CombiningType
{
/** @var ObjectType[] */
/** @var non-empty-list<ObjectType> */
private array $types;

private string $signature;

public function __construct(ObjectType ...$types)
public function __construct(ObjectType $type, ObjectType $otherType, ObjectType ...$otherTypes)
{
$this->types = $types;
$this->signature = implode('&', array_map(fn (Type $type) => $type->toString(), $types));
$this->types = [$type, $otherType, ...array_values($otherTypes)];
$this->signature = implode('&', array_map(fn (Type $type) => $type->toString(), $this->types));
}

public function accepts(mixed $value): bool
Expand Down Expand Up @@ -82,7 +83,7 @@ public function traverse(): array
}

/**
* @return ObjectType[]
* @return non-empty-list<ObjectType>
*/
public function types(): array
{
Expand Down
30 changes: 17 additions & 13 deletions src/Type/Types/UnionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,34 @@
/** @internal */
final class UnionType implements CombiningType
{
/** @var Type[] */
private array $types = [];
/** @var non-empty-list<Type> */
private array $types;

private string $signature;

public function __construct(Type ...$types)
public function __construct(Type $type, Type $otherType, Type ...$otherTypes)
{
$this->signature = implode('|', array_map(fn (Type $type) => $type->toString(), $types));
$types = [$type, $otherType, ...$otherTypes];
$filteredTypes = [];

foreach ($types as $type) {
if ($type instanceof self) {
foreach ($type->types as $subType) {
$this->types[] = $subType;
foreach ($types as $subType) {
if ($subType instanceof self) {
foreach ($subType->types as $anotherSubType) {
$filteredTypes[] = $anotherSubType;
}

continue;
}

if ($type instanceof MixedType) {
if ($subType instanceof MixedType) {
throw new ForbiddenMixedType();
}

$this->types[] = $type;
$filteredTypes[] = $subType;
}

$this->types = $filteredTypes;
$this->signature = implode('|', array_map(fn (Type $type) => $type->toString(), $this->types));
}

public function accepts(mixed $value): bool
Expand All @@ -65,12 +69,12 @@ public function matches(Type $other): bool
}

foreach ($this->types as $type) {
if (! $type->matches($other)) {
return false;
if ($type->matches($other)) {
return true;
}
}

return true;
return false;
}

public function isMatchedBy(Type $other): bool
Expand Down
2 changes: 1 addition & 1 deletion tests/Functional/Type/Parser/Lexer/GenericLexerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ public function test_generic_with_non_matching_array_key_type_for_template_throw

$this->expectException(InvalidAssignedGeneric::class);
$this->expectExceptionCode(1604613633);
$this->expectExceptionMessage("The generic `bool` is not a subtype of `int|string` for the template `Template` of the class `$className`.");
$this->expectExceptionMessage("The generic `bool` is not a subtype of `array-key` for the template `Template` of the class `$className`.");

$this->parser->parse("$className<bool>");
}
Expand Down
29 changes: 28 additions & 1 deletion tests/Integration/Mapping/Object/ScalarValuesMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public function test_values_are_mapped_properly(): void
'classString' => self::class,
'classStringOfDateTime' => DateTimeImmutable::class,
'classStringOfAlias' => stdClass::class,
'arrayKeyWithString' => 'foo',
'arrayKeyWithInteger' => 42,
];

foreach ([ScalarValues::class, ScalarValuesWithConstructor::class] as $class) {
Expand Down Expand Up @@ -81,6 +83,8 @@ public function test_values_are_mapped_properly(): void
self::assertSame(self::class, $result->classString);
self::assertSame(DateTimeImmutable::class, $result->classStringOfDateTime);
self::assertSame(stdClass::class, $result->classStringOfAlias);
self::assertSame('foo', $result->arrayKeyWithString);
self::assertSame(42, $result->arrayKeyWithInteger);
}
}

Expand All @@ -94,6 +98,17 @@ public function test_value_with_invalid_type_throws_exception(): void
self::assertSame('Value object(stdClass) is not a valid string.', (string)$error);
}
}

public function test_invalid_array_key_throws_exception(): void
{
try {
$this->mapperBuilder()->mapper()->map('array-key', new stdClass());
} catch (MappingError $exception) {
$error = $exception->node()->messages()[0];

self::assertSame('Value object(stdClass) is not a valid array key.', (string)$error);
}
}
}

class ScalarValues
Expand Down Expand Up @@ -173,6 +188,12 @@ class ScalarValues

/** @var class-string<ObjectAlias> */
public string $classStringOfAlias;

/** @var array-key */
public string|int $arrayKeyWithString;

/** @var array-key */
public string|int $arrayKeyWithInteger;
}

class ScalarValuesWithConstructor extends ScalarValues
Expand Down Expand Up @@ -200,6 +221,8 @@ class ScalarValuesWithConstructor extends ScalarValues
* @param class-string $classString
* @param class-string<DateTimeInterface> $classStringOfDateTime
* @param class-string<ObjectAlias> $classStringOfAlias
* @param array-key $arrayKeyWithString
* @param array-key $arrayKeyWithInteger
*/
public function __construct(
bool $boolean,
Expand Down Expand Up @@ -228,7 +251,9 @@ public function __construct(
string $stringValueContainingSpecialCharsWithDoubleQuote,
string $classString,
string $classStringOfDateTime,
string $classStringOfAlias
string $classStringOfAlias,
int|string $arrayKeyWithString,
int|string $arrayKeyWithInteger,
) {
$this->boolean = $boolean;
$this->float = $float;
Expand Down Expand Up @@ -257,5 +282,7 @@ public function __construct(
$this->classString = $classString;
$this->classStringOfDateTime = $classStringOfDateTime;
$this->classStringOfAlias = $classStringOfAlias;
$this->arrayKeyWithString = $arrayKeyWithString;
$this->arrayKeyWithInteger = $arrayKeyWithInteger;
}
}
11 changes: 11 additions & 0 deletions tests/Integration/Mapping/Other/FlexibleCastingMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,17 @@ public function test_source_value_is_casted_when_other_type_cannot_be_casted():

self::assertSame('foo', $result);
}

public function test_source_can_be_casted_to_array_key(): void
{
try {
$result = $this->mapper->map('array-key', new StringableObject('foo'));
} catch (MappingError $error) {
$this->mappingFail($error);
}

self::assertSame('foo', $result);
}
}

interface SomeInterfaceForClassWithNoProperties {}
Expand Down
Loading
Loading