Skip to content

Commit

Permalink
feat: allow any string or integer in array key
Browse files Browse the repository at this point in the history
The following array key types are now accepted and will work properly
with the mapper:

```php
$mapper->map("array<'foo'|'bar', string>", ['foo' => 'foo']);
$mapper->map('array<42|1337, string>', [42 => 'foo']);
$mapper->map('array<positive-int, string>', [42 => 'foo']);
$mapper->map('array<negative-int, string>', [-42 => 'foo']);
$mapper->map('array<int<-42, 1337>, string>', [42 => 'foo']);
$mapper->map('array<non-empty-string, string>', ['foo' => 'foo']);
$mapper->map('array<class-string, string>', ['SomeClass' => 'foo']);
```
  • Loading branch information
romm committed Aug 18, 2023
1 parent c4acb17 commit 12af3ed
Show file tree
Hide file tree
Showing 10 changed files with 267 additions and 96 deletions.
12 changes: 12 additions & 0 deletions docs/pages/usage/type-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ final class SomeClass
/** @var array<int, SomeClass> */
private array $arrayOfClassWithIntegerKeys,

/** @var array<non-empty-string, string> */
private array $arrayOfClassWithNonEmptyStringKeys,

/** @var array<'foo'|'bar', string> */
private array $arrayOfClassWithStringValueKeys,

/** @var array<42|1337, string> */
private array $arrayOfClassWithIntegerValueKeys,

/** @var array<positive-int, string> */
private array $arrayOfClassWithPositiveIntegerValueKeys,

/** @var non-empty-array<string> */
private array $nonEmptyArrayOfStrings,

Expand Down
9 changes: 5 additions & 4 deletions src/Mapper/Tree/Builder/ArrayNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@ private function children(CompositeTraversableType $type, Shell $shell, RootNode
$children = [];

foreach ($values as $key => $value) {
$child = $shell->child((string)$key, $subType);

if (! $keyType->accepts($key)) {
throw new InvalidTraversableKey($key, $keyType);
$children[$key] = TreeNode::error($child, new InvalidTraversableKey($key, $keyType));
} else {
$children[$key] = $rootBuilder->build($child->withValue($value));
}

$child = $shell->child((string)$key, $subType)->withValue($value);
$children[$key] = $rootBuilder->build($child);
}

return $children;
Expand Down
15 changes: 2 additions & 13 deletions src/Type/Parser/Exception/Iterable/InvalidArrayKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,15 @@

use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ArrayType;
use CuyZ\Valinor\Type\Types\NonEmptyArrayType;
use RuntimeException;

/** @internal */
final class InvalidArrayKey extends RuntimeException implements InvalidType
{
/**
* @param class-string<ArrayType|NonEmptyArrayType> $arrayType
*/
public function __construct(string $arrayType, Type $keyType, Type $subType)
public function __construct(Type $keyType)
{
$signature = "array<{$keyType->toString()}, {$subType->toString()}>";

if ($arrayType === NonEmptyArrayType::class) {
$signature = "non-empty-array<{$keyType->toString()}, {$subType->toString()}>";
}

parent::__construct(
"Invalid key type `{$keyType->toString()}` for `$signature`. It must be one of `array-key`, `int` or `string`.",
"Invalid array key type `{$keyType->toString()}`, it must be a valid string or integer.",
1604335007
);
}
Expand Down
16 changes: 3 additions & 13 deletions src/Type/Parser/Lexer/Token/ArrayToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,14 @@
namespace CuyZ\Valinor\Type\Parser\Lexer\Token;

use CuyZ\Valinor\Type\CompositeTraversableType;
use CuyZ\Valinor\Type\IntegerType;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ArrayClosingBracketMissing;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ArrayCommaMissing;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\InvalidArrayKey;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayClosingBracketMissing;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayColonTokenMissing;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayCommaMissing;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayElementTypeMissing;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayEmptyElements;
use CuyZ\Valinor\Type\Parser\Lexer\TokenStream;
use CuyZ\Valinor\Type\StringType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ArrayKeyType;
use CuyZ\Valinor\Type\Types\ArrayType;
Expand Down Expand Up @@ -84,17 +81,10 @@ private function arrayType(TokenStream $stream): CompositeTraversableType
throw new ArrayCommaMissing($this->arrayType, $type);
}

$keyType = ArrayKeyType::from($type);
$subType = $stream->read();

if ($type instanceof ArrayKeyType) {
$arrayType = new ($this->arrayType)($type, $subType);
} elseif ($type instanceof IntegerType) {
$arrayType = new ($this->arrayType)(ArrayKeyType::integer(), $subType);
} elseif ($type instanceof StringType) {
$arrayType = new ($this->arrayType)(ArrayKeyType::string(), $subType);
} else {
throw new InvalidArrayKey($this->arrayType, $type, $subType);
}
$arrayType = new ($this->arrayType)($keyType, $subType);

if ($stream->done() || ! $stream->forward() instanceof ClosingBracketToken) {
throw new ArrayClosingBracketMissing($arrayType);
Expand Down Expand Up @@ -138,7 +128,7 @@ private function shapedArrayType(TokenStream $stream): ShapedArrayType
}

if ($stream->done()) {
$elements[] = new ShapedArrayElement(new StringValueType((string)$index), $type);
$elements[] = new ShapedArrayElement(new IntegerValueType($index), $type);

throw new ShapedArrayClosingBracketMissing($elements);
}
Expand Down
52 changes: 32 additions & 20 deletions src/Type/Types/ArrayKeyType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace CuyZ\Valinor\Type\Types;

use CuyZ\Valinor\Type\CombiningType;
use CuyZ\Valinor\Type\IntegerType;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\InvalidArrayKey;
use CuyZ\Valinor\Type\StringType;
use CuyZ\Valinor\Type\Type;

Expand All @@ -13,32 +15,34 @@
/** @internal */
final class ArrayKeyType implements Type
{
private static self $default;

private static self $integer;

private static self $string;

private static self $integerAndString;

/** @var array<IntegerType|StringType> */
/** @var array<Type> */
private array $types;

private string $signature;

/**
* @codeCoverageIgnore
* @infection-ignore-all
*/
private function __construct(IntegerType|StringType ...$types)
private function __construct(Type $type)
{
$this->types = $types;
$this->signature = count($this->types) === 1
? $this->types[0]->toString()
: 'array-key';
$this->signature = $type->toString();
$this->types = $type instanceof CombiningType
? [...$type->types()]
: [$type];

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

public static function default(): self
{
return self::$integerAndString ??= new self(NativeIntegerType::get(), NativeStringType::get());
return self::$default ??= new self(new UnionType(NativeIntegerType::get(), NativeStringType::get()));
}

public static function integer(): self
Expand All @@ -51,16 +55,24 @@ public static function string(): self
return self::$string ??= new self(NativeStringType::get());
}

public function accepts(mixed $value): bool
public static function from(Type $type): ?self
{
// If an array key can be evaluated as an integer, it will always be
// cast to an integer, even if the actual key is a string.
if (is_int($value)) {
return true;
}
return match (true) {
$type instanceof self => $type,
$type instanceof NativeIntegerType => self::integer(),
$type instanceof NativeStringType => self::string(),
default => new self($type),
};
}

public function accepts(mixed $value): bool
{
foreach ($this->types as $type) {
if ($type->accepts($value)) {
// If an array key can be evaluated as an integer, it will always be
// cast to an integer, even if the actual key is a string.
if (is_int($value) && $type instanceof NativeStringType) {
return true;
} elseif ($type->accepts($value)) {
return true;
}
}
Expand Down
4 changes: 2 additions & 2 deletions tests/Functional/Type/Parser/Lexer/NativeLexerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1040,7 +1040,7 @@ public function test_invalid_array_key_throws_exception(): void
{
$this->expectException(InvalidArrayKey::class);
$this->expectExceptionCode(1604335007);
$this->expectExceptionMessage('Invalid key type `float` for `array<float, string>`. It must be one of `array-key`, `int` or `string`.');
$this->expectExceptionMessage('Invalid array key type `float`, it must be a valid string or integer.');

$this->parser->parse('array<float, string>');
}
Expand All @@ -1049,7 +1049,7 @@ public function test_invalid_non_empty_array_key_throws_exception(): void
{
$this->expectException(InvalidArrayKey::class);
$this->expectExceptionCode(1604335007);
$this->expectExceptionMessage('Invalid key type `float` for `non-empty-array<float, string>`. It must be one of `array-key`, `int` or `string`.');
$this->expectExceptionMessage('Invalid array key type `float`, it must be a valid string or integer.');

$this->parser->parse('non-empty-array<float, string>');
}
Expand Down
Loading

0 comments on commit 12af3ed

Please sign in to comment.