allow vendord
All checks were successful
Build, Push and Deploy / build-and-push (push) Successful in 3m3s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 16s
Build, Push and Deploy / deploy-staging (push) Successful in 32s
Build, Push and Deploy / deploy-production (push) Has been skipped
Build, Push and Deploy / cleanup (push) Successful in 1s

This commit is contained in:
2026-03-30 14:54:57 +07:00
parent 66aed7c4e8
commit b5e3a778ce
21316 changed files with 2777892 additions and 13 deletions

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer;
use CuyZ\Valinor\Normalizer\Transformer\EmptyObject;
use CuyZ\Valinor\Normalizer\Transformer\Transformer;
/**
* @api
*
* @implements Normalizer<array<mixed>|scalar|null>
*/
final class ArrayNormalizer implements Normalizer
{
/**
* @internal
*/
public function __construct(
private Transformer $transformer,
) {}
/** @pure */
public function normalize(mixed $value): mixed
{
/** @var array<mixed>|scalar|null */
return $this->format(
$this->transformer->transform($value),
);
}
private function format(mixed $value): mixed
{
if (is_iterable($value)) {
if (! is_array($value)) {
$value = iterator_to_array($value);
}
$value = array_map($this->format(...), $value);
} elseif ($value instanceof EmptyObject) {
return [];
}
return $value;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer;
use Attribute;
/**
* This attribute can be used to automatically register a transformer attribute.
*
* When there is no control over the transformer attribute class, the following
* method can be used: @see \CuyZ\Valinor\NormalizerBuilder::registerTransformer
*
* ```php
* namespace My\App;
*
* #[\CuyZ\Valinor\Normalizer\AsTransformer]
* #[\Attribute(\Attribute::TARGET_PROPERTY)]
* final class DateTimeFormat
* {
* public function __construct(private string $format) {}
*
* public function normalize(\DateTimeInterface $date): string
* {
* return $date->format($this->format);
* }
* }
*
* final readonly class Event
* {
* public function __construct(
* public string $eventName,
* #[\My\App\DateTimeFormat('Y/m/d')]
* public \DateTimeInterface $date,
* ) {}
* }
*
* (new \CuyZ\Valinor\NormalizerBuilder())
* ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
* ->normalize(new \My\App\Event(
* eventName: 'Release of legendary album',
* date: new \DateTimeImmutable('1971-11-08'),
* ));
*
* // [
* // 'eventName' => 'Release of legendary album',
* // 'date' => '1971/11/08',
* // ]
* ```
*
* @api
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class AsTransformer {}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Exception;
use RuntimeException;
/** @internal */
final class CircularReferenceFoundDuringNormalization extends RuntimeException
{
public function __construct(object $object)
{
$class = $object::class;
parent::__construct(
"A circular reference was detected with an object of type `$class`. Circular references are not supported by the normalizer.",
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Exception;
use CuyZ\Valinor\Definition\MethodDefinition;
use LogicException;
/** @internal */
final class KeyTransformerHasTooManyParameters extends LogicException
{
public function __construct(MethodDefinition $method)
{
parent::__construct(
"Key transformer must have at most 1 parameter, {$method->parameters->count()} given for `$method->signature`.",
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Exception;
use CuyZ\Valinor\Definition\MethodDefinition;
use LogicException;
/** @internal */
final class KeyTransformerParameterInvalidType extends LogicException
{
public function __construct(MethodDefinition $method)
{
parent::__construct(
"Key transformer parameter must be a string, {$method->parameters->at(0)->type->toString()} given for `$method->signature`.",
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Exception;
use CuyZ\Valinor\Definition\FunctionDefinition;
use CuyZ\Valinor\Definition\MethodDefinition;
use CuyZ\Valinor\Type\Type;
use LogicException;
/** @internal */
final class TransformerHasInvalidCallableParameter extends LogicException
{
public function __construct(MethodDefinition|FunctionDefinition $method, Type $parameterType)
{
parent::__construct(
"Transformer's second parameter must be a callable, `{$parameterType->toString()}` given for `$method->signature`.",
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Exception;
use CuyZ\Valinor\Definition\FunctionDefinition;
use CuyZ\Valinor\Definition\MethodDefinition;
use LogicException;
/** @internal */
final class TransformerHasNoParameter extends LogicException
{
public function __construct(MethodDefinition|FunctionDefinition $method)
{
parent::__construct("Transformer must have at least one parameter, none given for `$method->signature`.");
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Exception;
use CuyZ\Valinor\Definition\FunctionDefinition;
use CuyZ\Valinor\Definition\MethodDefinition;
use LogicException;
/** @internal */
final class TransformerHasTooManyParameters extends LogicException
{
public function __construct(MethodDefinition|FunctionDefinition $method)
{
parent::__construct(
"Transformer must have at most 2 parameters, {$method->parameters->count()} given for `$method->signature`.",
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Exception;
use RuntimeException;
use function get_debug_type;
/** @internal */
final class TypeUnhandledByNormalizer extends RuntimeException
{
public function __construct(mixed $value)
{
$type = get_debug_type($value);
parent::__construct("Value of type `$type` cannot be normalized.");
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer;
/**
* @api
*
* @template T of Normalizer
*/
final class Format
{
/**
* Allows a normalizer to format an input to a PHP array, containing only
* scalar values.
*
* ```php
* namespace My\App;
*
* $normalizer = (new \CuyZ\Valinor\NormalizerBuilder())
* ->normalizer(\CuyZ\Valinor\Normalizer\Format::array());
*
* $userAsArray = $normalizer->normalize(
* new \My\App\User(
* name: 'John Doe',
* age: 42,
* country: new \My\App\Country(
* name: 'France',
* countryCode: 'FR',
* ),
* )
* );
*
* // `$userAsArray` is now an array and can be manipulated much more easily, for
* // instance to be serialized to the wanted data format.
* //
* // [
* // 'name' => 'John Doe',
* // 'age' => 42,
* // 'country' => [
* // 'name' => 'France',
* // 'countryCode' => 'FR',
* // ],
* // ];
* ```
*
* @pure
* @return self<ArrayNormalizer>
*/
public static function array(): self
{
return new self(ArrayNormalizer::class);
}
/**
* Allows a normalizer to format an input to JSON syntax.
*
* ```php
* namespace My\App;
*
* $normalizer = (new \CuyZ\Valinor\NormalizerBuilder())
* ->normalizer(\CuyZ\Valinor\Normalizer\Format::json());
*
* $userAsJson = $normalizer->normalize(
* new \My\App\User(
* name: 'John Doe',
* age: 42,
* country: new \My\App\Country(
* name: 'France',
* code: 'FR',
* ),
* )
* );
*
* // `$userAsJson` is a valid JSON string representing the data:
* // {"name":"John Doe","age":42,"country":{"name":"France","code":"FR"}}
* ```
*
* @pure
* @return self<JsonNormalizer>
*/
public static function json(): self
{
return new self(JsonNormalizer::class);
}
/**
* @param class-string<T> $type
*/
private function __construct(private string $type) {}
/**
* @pure
* @return class-string<T>
*/
public function type(): string
{
return $this->type;
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Formatter\Exception;
use RuntimeException;
/** @internal */
final class CannotFormatInvalidTypeToJson extends RuntimeException
{
public function __construct(mixed $value)
{
$type = get_debug_type($value);
parent::__construct("Value of type `$type` cannot be normalized to JSON.");
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Formatter;
use CuyZ\Valinor\Normalizer\Formatter\Exception\CannotFormatInvalidTypeToJson;
use CuyZ\Valinor\Normalizer\Transformer\EmptyObject;
use Generator;
use function array_is_list;
use function assert;
use function fwrite;
use function is_bool;
use function is_iterable;
use function is_null;
use function is_scalar;
use function json_encode;
use const JSON_FORCE_OBJECT;
/** @internal */
final class JsonFormatter
{
private bool $prettyPrint;
private bool $forceObject;
public function __construct(
/** @var resource */
private readonly mixed $resource,
private readonly int $jsonEncodingOptions,
) {
$this->prettyPrint = (bool)($this->jsonEncodingOptions & JSON_PRETTY_PRINT);
$this->forceObject = (bool)($this->jsonEncodingOptions & JSON_FORCE_OBJECT);
}
/**
* @return resource
*/
public function format(mixed $value): mixed
{
$this->formatRecursively($value, 1);
return $this->resource;
}
private function formatRecursively(mixed $value, int $depth): void
{
if (is_null($value)) {
$this->write('null');
} elseif (is_bool($value)) {
$this->write($value ? 'true' : 'false');
} elseif (is_scalar($value)) {
/**
* @phpstan-ignore-next-line / Due to the new json encoding options feature, it is not possible to let SA
* tools understand that JSON_THROW_ON_ERROR is always set.
*/
$this->write(json_encode($value, $this->jsonEncodingOptions));
} elseif ($value instanceof EmptyObject) {
$this->write('{}');
} elseif (is_iterable($value)) {
// Note: when a generator is formatted, it is considered as a list
// if its first key is 0. This is done early because the first JSON
// character for an array differs from the one for an object, and we
// need to know that before actually looping on the generator.
//
// For generators having a first key of 0 and inconsistent keys
// afterward, this leads to a JSON array being written, while it
// should have been an object. This is a trade-off we accept,
// considering most generators starting at 0 are actually lists.
if ($this->forceObject) {
$isList = false;
} elseif ($value instanceof Generator) {
if (! $value->valid()) {
$this->write('[]');
return;
}
$isList = $value->key() === 0;
} else {
/** @var array<mixed> $value At this point we know this is an array */
$isList = array_is_list($value);
}
$isFirst = true;
$this->write($isList ? '[' : '{');
foreach ($value as $key => $val) {
$chunk = '';
if (! $isFirst) {
$chunk = ',';
}
if ($this->prettyPrint) {
$chunk .= PHP_EOL . str_repeat(' ', $depth);
}
$isFirst = false;
if (! $isList) {
assert(is_scalar($key));
$key = json_encode((string)$key, $this->jsonEncodingOptions);
$chunk .= $key . ':';
if ($this->prettyPrint) {
$chunk .= ' ';
}
}
$this->write($chunk);
$this->formatRecursively($val, $depth + 1);
}
$chunk = '';
if ($this->prettyPrint) {
$chunk = PHP_EOL . str_repeat(' ', $depth - 1);
}
$chunk .= $isList ? ']' : '}';
$this->write($chunk);
} else {
throw new CannotFormatInvalidTypeToJson($value);
}
}
private function write(string $content): void
{
fwrite($this->resource, $content);
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer;
use CuyZ\Valinor\Normalizer\Formatter\JsonFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Transformer;
use RuntimeException;
use function get_debug_type;
use function is_resource;
use const JSON_FORCE_OBJECT;
use const JSON_HEX_AMP;
use const JSON_HEX_APOS;
use const JSON_HEX_QUOT;
use const JSON_HEX_TAG;
use const JSON_INVALID_UTF8_IGNORE;
use const JSON_INVALID_UTF8_SUBSTITUTE;
use const JSON_NUMERIC_CHECK;
use const JSON_PRESERVE_ZERO_FRACTION;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_LINE_TERMINATORS;
use const JSON_UNESCAPED_SLASHES;
use const JSON_UNESCAPED_UNICODE;
/**
* @api
*
* @implements Normalizer<string>
*/
final class JsonNormalizer implements Normalizer
{
private const ACCEPTABLE_JSON_OPTIONS = JSON_FORCE_OBJECT
| JSON_HEX_QUOT
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_INVALID_UTF8_IGNORE
| JSON_INVALID_UTF8_SUBSTITUTE
| JSON_NUMERIC_CHECK
| JSON_PRETTY_PRINT
| JSON_PRESERVE_ZERO_FRACTION
| JSON_UNESCAPED_LINE_TERMINATORS
| JSON_UNESCAPED_SLASHES
| JSON_UNESCAPED_UNICODE
| JSON_THROW_ON_ERROR;
private Transformer $transformer;
private int $jsonEncodingOptions;
/**
* Internal note
* -------------
*
* We could use the `int-mask-of<JsonNormalizer::JSON_*>` annotation
* to let PHPStan infer the type of the accepted options, but some caveats
* were found:
* - SA tools are not able to infer that we end up having only accepted
* options. Might be fixed with https://github.com/phpstan/phpstan/issues/9384
* for PHPStan but Psalm does have some (not all) issues as well.
* - Using this annotation provokes *severe* performance issues when
* running PHPStan analysis, therefore it is preferable to avoid it.
*
* @internal
*/
public function __construct(
Transformer $transformer,
int $jsonEncodingOptions = JSON_THROW_ON_ERROR,
) {
$this->transformer = $transformer;
$this->jsonEncodingOptions = (self::ACCEPTABLE_JSON_OPTIONS & $jsonEncodingOptions) | JSON_THROW_ON_ERROR;
}
/**
* By default, the JSON normalizer will only use `JSON_THROW_ON_ERROR` to
* encode non-boolean scalar values. There might be use-cases where projects
* will need flags like `JSON_JSON_PRESERVE_ZERO_FRACTION`.
*
* This can be achieved by passing these flags to this method:
*
* ```php
* $normalizer = (new \CuyZ\Valinor\NormalizerBuilder())
* ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
* ->withOptions(\JSON_PRESERVE_ZERO_FRACTION);
*
* $lowerManhattanAsJson = $normalizer->normalize(
* new \My\App\Coordinates(
* longitude: 40.7128,
* latitude: -74.0000
* )
* );
*
* // `$lowerManhattanAsJson` is a valid JSON string representing the data:
* // {"longitude":40.7128,"latitude":-74.0000}
* ```
*
* @pure
*/
public function withOptions(int $options): self
{
return new self($this->transformer, $options);
}
/** @pure */
public function normalize(mixed $value): string
{
$result = $this->transformer->transform($value);
/** @var resource $resource */
$resource = fopen('php://memory', 'w');
$formatter = new JsonFormatter($resource, $this->jsonEncodingOptions);
$formatter->format($result);
rewind($resource);
/** @var string */
$json = stream_get_contents($resource);
fclose($resource);
return $json;
}
/**
* Returns a new normalizer that will write the JSON to the given resource
* instead of returning a string.
*
* A benefit of streaming the data to a PHP resource is that it may be more
* memory-efficient when using generators for instance when querying a
* database:
*
* ```php
* // In this example, we assume that the result of the query below is a
* // generator, every entry will be yielded one by one, instead of
* // everything being loaded in memory at once.
* $users = $database->execute('SELECT * FROM users');
*
* $file = fopen('path/to/some_file.json', 'w');
*
* $normalizer = (new \CuyZ\Valinor\NormalizerBuilder())
* ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
* ->streamTo($file);
*
* // Even if there are thousands of users, memory usage will be kept low
* // when writing JSON into the file.
* $normalizer->normalize($users);
* ```
*
* @pure
* @param resource $resource
*/
public function streamTo(mixed $resource): StreamNormalizer
{
// This check is there to help people that do not use static analyzers.
if (! is_resource($resource)) {
throw new RuntimeException('Expected a valid resource, got ' . get_debug_type($resource));
}
return new StreamNormalizer($this->transformer, new JsonFormatter($resource, $this->jsonEncodingOptions));
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer;
/**
* @api
*
* @template-covariant T
*/
interface Normalizer
{
/**
* A normalizer is a service that transforms a given input into scalar and
* array values, while preserving the original structure.
*
* This feature can be used to share information with other systems that use
* a data format (JSON, CSV, XML, etc.). The normalizer will take care of
* recursively transforming the data into a format that can be serialized.
*
* @pure
* @return T
*/
public function normalize(mixed $value): mixed;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer;
use CuyZ\Valinor\Normalizer\Formatter\JsonFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Transformer;
/**
* @api
*
* @implements Normalizer<resource>
*/
final class StreamNormalizer implements Normalizer
{
/**
* @internal
*/
public function __construct(
private Transformer $transformer,
private JsonFormatter $formatter,
) {}
/** @pure */
public function normalize(mixed $value): mixed
{
$result = $this->transformer->transform($value);
return $this->formatter->format($result);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer;
use Closure;
use CuyZ\Valinor\Cache\Cache;
use CuyZ\Valinor\Cache\CacheEntry;
use CuyZ\Valinor\Cache\TypeFilesWatcher;
use CuyZ\Valinor\Compiler\Compiler;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Exception\TypeUnhandledByNormalizer;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerRootNode;
use CuyZ\Valinor\Type\CompositeTraversableType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ArrayKeyType;
use CuyZ\Valinor\Type\Types\ArrayType;
use CuyZ\Valinor\Type\Types\EnumType;
use CuyZ\Valinor\Type\Types\Factory\ValueTypeFactory;
use CuyZ\Valinor\Type\Types\IterableType;
use CuyZ\Valinor\Type\Types\NativeBooleanType;
use CuyZ\Valinor\Type\Types\NativeClassType;
use CuyZ\Valinor\Type\Types\NativeFloatType;
use CuyZ\Valinor\Type\Types\NativeIntegerType;
use CuyZ\Valinor\Type\Types\NativeStringType;
use CuyZ\Valinor\Type\Types\NonEmptyArrayType;
use CuyZ\Valinor\Type\Types\NonEmptyListType;
use CuyZ\Valinor\Type\Types\NullType;
use Generator;
use Iterator;
use IteratorAggregate;
use UnitEnum;
use function array_is_list;
use function is_array;
use function is_iterable;
use function is_object;
use function is_scalar;
/** @internal */
final class CompiledTransformer implements Transformer
{
public function __construct(
private TransformerDefinitionBuilder $definitionBuilder,
private TypeFilesWatcher $typeFilesWatcher,
/** @var Cache<Transformer> */
private Cache $cache,
/** @var list<callable> */
private array $transformers,
) {}
public function transform(mixed $value): mixed
{
$type = $this->inferType($value, isSure: true);
$key = "transformer-\0" . $type->toString();
$transformer = $this->cache->get($key, $this->transformers, $this);
if ($transformer) {
return $transformer->transform($value);
}
$code = $this->compileFor($type);
$filesToWatch = $this->typeFilesWatcher->for($type);
$this->cache->set($key, new CacheEntry($code, $filesToWatch));
$transformer = $this->cache->get($key, $this->transformers, $this);
assert($transformer instanceof Transformer);
return $transformer->transform($value);
}
private function compileFor(Type $type): string
{
$rootNode = new TransformerRootNode($this->definitionBuilder, $type);
$node = Node::shortClosure($rootNode)
->witParameters(
Node::parameterDeclaration('transformers', 'array'),
Node::parameterDeclaration('delegate', Transformer::class),
);
return (new Compiler())->compile($node)->code();
}
private function inferType(mixed $value, bool $isSure = false): Type
{
return match (true) {
$value instanceof UnitEnum => EnumType::native($value::class),
is_object($value) && ! $value instanceof Closure && ! $value instanceof Generator => $this->inferObjectType($value),
is_iterable($value) => $this->inferIterableType($value),
is_scalar($value) && $isSure => ValueTypeFactory::from($value),
is_string($value) => NativeStringType::get(),
is_int($value) => NativeIntegerType::get(),
is_float($value) => NativeFloatType::get(),
is_bool($value) => NativeBooleanType::get(),
is_null($value) => NullType::get(),
default => throw new TypeUnhandledByNormalizer($value),
};
}
private function inferObjectType(object $value): NativeClassType
{
if (is_iterable($value)) {
$iterableType = $this->inferIterableType($value);
return new NativeClassType($value::class, [$iterableType->subType()]);
}
return new NativeClassType($value::class);
}
/**
* This method contains a strongly opinionated rule: when normalizing an
* iterable, we assume that the iterable has a high probability of
* containing only one type of value, each iteration matching the type of
* the first value.
*
* This is a trade-off between performance and accuracy: the first value
* will always be normalized, so we can safely add its type to the compiling
* process. For other values, if their types match the first one, that is a
* big performance win; if they don't, the transformer will do its best to
* find an optimized way of dealing with it or, by default, fall back to
* the delegate transformer.
*
* @param iterable<mixed> $value
*/
private function inferIterableType(iterable $value): CompositeTraversableType
{
if (is_array($value)) {
if ($value === []) {
return ArrayType::native();
}
$firstValueType = $this->inferType(reset($value));
if (array_is_list($value)) {
return new NonEmptyListType($firstValueType);
}
return new NonEmptyArrayType(ArrayKeyType::default(), $firstValueType);
}
if ($value instanceof IteratorAggregate) {
$value = $value->getIterator();
}
if ($value instanceof Iterator && $value->valid()) {
$firstValueType = $this->inferType($value->current());
return new IterableType(ArrayKeyType::default(), $firstValueType);
}
return IterableType::native();
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler;
use CuyZ\Valinor\Definition\AttributeDefinition;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\RegisteredTransformersFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\TypeFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\UnsureTypeFormatter;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\UnresolvableType;
use function array_reverse;
/** @internal */
final class TransformerDefinition
{
/**
* This flag is used to indicate that we are sure what the type of this
* definition is. This is important to ensure that the normalizer will act
* correctly, even if the value of a property does not respect the expected
* type.
*
* For instance, in the following case we know that the value is a string:
*
* ```php
* final class SomeClass
* {
* public string $value;
* }
* ```
*
* However, in the following case, we cannot guarantee the type of the
* value, as it may contain any value.
*
* ```php
* final class SomeClass
* {
* /** @var string *\
* public $value;
* }
* ```
*/
private bool $isSure = false;
/** @var list<AttributeDefinition> */
private array $transformerAttributes = [];
public function __construct(
public readonly Type $type,
/** @var array<int, Type> */
private array $transformerTypes,
private TypeFormatter $typeFormatter,
) {}
public function typeFormatter(): TypeFormatter
{
$typeFormatter = $this->typeFormatter;
if ($this->transformerTypes !== [] || $this->transformerAttributes !== []) {
$typeFormatter = new RegisteredTransformersFormatter(
$this->type,
$typeFormatter,
$this->transformerTypes,
$this->transformerAttributes,
);
}
if (! $this->isSure && ! $this->type instanceof UnresolvableType) {
return new UnsureTypeFormatter($typeFormatter, $this->type);
}
return $typeFormatter;
}
/**
* @param list<AttributeDefinition> $transformerAttributes
*/
public function withTransformerAttributes(array $transformerAttributes): self
{
$self = clone $this;
$self->transformerAttributes = [...$self->transformerAttributes, ...array_reverse($transformerAttributes)];
return $self;
}
public function hasTransformers(): bool
{
return $this->transformerTypes !== [] || $this->transformerAttributes !== [];
}
public function markAsSure(): self
{
$self = clone $this;
$self->isSure = true;
return $self;
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler;
use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository;
use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\ClassFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\DateTimeFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\DateTimeZoneFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\EnumFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\InterfaceFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\MixedFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\NullFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\ScalarFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\ShapedArrayFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\StdClassFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\TraversableFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\TypeFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\UnionFormatter;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter\UnitEnumFormatter;
use CuyZ\Valinor\Normalizer\Transformer\TransformerContainer;
use CuyZ\Valinor\Type\ClassType;
use CuyZ\Valinor\Type\CompositeTraversableType;
use CuyZ\Valinor\Type\ScalarType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\EnumType;
use CuyZ\Valinor\Type\Types\GenericType;
use CuyZ\Valinor\Type\Types\InterfaceType;
use CuyZ\Valinor\Type\Types\MixedType;
use CuyZ\Valinor\Type\Types\NativeClassType;
use CuyZ\Valinor\Type\Types\NullType;
use CuyZ\Valinor\Type\Types\ShapedArrayType;
use CuyZ\Valinor\Type\Types\UnionType;
use DateTimeInterface;
use DateTimeZone;
use stdClass;
use Traversable;
use UnitEnum;
/** @internal */
final class TransformerDefinitionBuilder
{
public function __construct(
private ClassDefinitionRepository $classDefinitionRepository,
private FunctionDefinitionRepository $functionDefinitionRepository,
private TransformerContainer $transformerContainer,
) {}
public function for(Type $type): TransformerDefinition
{
$definition = new TransformerDefinition(
type: $type,
transformerTypes: $this->transformerTypes($type),
typeFormatter: $this->typeFormatter($type),
);
if ($type instanceof ClassType) {
// A class may have transformer attributes, in which case they are
// added to the list of attributes for this definition.
$definition = $definition->withTransformerAttributes(
$this->classDefinitionRepository
->for($type)
->attributes
->filter(TransformerContainer::filterTransformerAttributes(...))
->toArray(),
);
}
if ($type instanceof MixedType) {
$definition = $definition->markAsSure();
}
return $definition;
}
/**
* @return array<int, Type>
*/
private function transformerTypes(Type $type): array
{
// If the type is a union type, we don't need to add the transformer
// because it will be added for each subtype anyway. This condition also
// prevents the transformers from being applied twice in these cases.
if ($type instanceof UnionType) {
return [];
}
$types = [];
foreach ($this->transformerContainer->transformers() as $key => $transformer) {
$function = $this->functionDefinitionRepository->for($transformer);
$transformerType = $function->parameters->at(0)->type;
if (! $type->matches($transformerType)) {
continue;
}
$types[$key] = $transformerType;
}
return array_reverse($types, preserve_keys: true);
}
private function typeFormatter(Type $type): TypeFormatter
{
return match (true) {
$type instanceof EnumType => new EnumFormatter($type),
$type instanceof InterfaceType => new InterfaceFormatter($type),
$type instanceof NativeClassType => match (true) {
$type->className() === UnitEnum::class => new UnitEnumFormatter(),
$type->className() === stdClass::class => new StdClassFormatter(),
is_a($type->className(), DateTimeInterface::class, true) => new DateTimeFormatter(),
is_a($type->className(), DateTimeZone::class, true) => new DateTimeZoneFormatter(),
is_a($type->className(), Traversable::class, true) => new TraversableFormatter($type->generics()[0] ?? MixedType::get()),
default => new ClassFormatter($this->classDefinitionRepository->for($type)),
},
$type instanceof NullType => new NullFormatter(),
$type instanceof ScalarType => new ScalarFormatter(),
$type instanceof ShapedArrayType => new ShapedArrayFormatter($type),
$type instanceof UnionType => new UnionFormatter($type),
$type instanceof CompositeTraversableType => new TraversableFormatter($type->subType()),
$type instanceof GenericType => $this->typeFormatter($type->innerType),
default => new MixedFormatter(),
};
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler;
use CuyZ\Valinor\Compiler\Compiler;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\MethodNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Transformer;
use CuyZ\Valinor\Type\Type;
use WeakMap;
/** @internal */
final class TransformerRootNode extends Node
{
public function __construct(
private TransformerDefinitionBuilder $definitionBuilder,
private Type $type,
) {}
public function compile(Compiler $compiler): Compiler
{
$definition = $this->definitionBuilder->for($this->type);
$definition = $definition->markAsSure();
$classNode = $this->transformerClassNode($definition);
$classNode = $definition->typeFormatter()->manipulateTransformerClass($classNode, $this->definitionBuilder);
return $classNode->compile($compiler);
}
private function transformerClassNode(TransformerDefinition $definition): AnonymousClassNode
{
return Node::anonymousClass()
->implements(Transformer::class)
->withArguments(
Node::variable('transformers'),
Node::variable('delegate'),
)
->withProperties(
Node::propertyDeclaration('transformers', 'array'),
Node::propertyDeclaration('delegate', Transformer::class),
)
->withMethods(
MethodNode::constructor()
->withVisibility('public')
->witParameters(
Node::parameterDeclaration('transformers', 'array'),
Node::parameterDeclaration('delegate', Transformer::class),
)
->withBody(
Node::property('transformers')->assign(Node::variable('transformers'))->asExpression(),
Node::property('delegate')->assign(Node::variable('delegate'))->asExpression(),
),
Node::method('transform')
->withVisibility('public')
->witParameters(
Node::parameterDeclaration('value', 'mixed'),
)
->withReturnType('mixed')
->withBody(
Node::variable('references')->assign(Node::newClass(WeakMap::class))->asExpression(),
Node::return(
$definition->typeFormatter()->formatValueNode(
Node::variable('value'),
),
),
),
);
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Library\NewAttributeNode;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Definition\AttributeDefinition;
use CuyZ\Valinor\Definition\ClassDefinition;
use CuyZ\Valinor\Normalizer\Exception\CircularReferenceFoundDuringNormalization;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Normalizer\Transformer\TransformerContainer;
use CuyZ\Valinor\Type\ScalarType;
use CuyZ\Valinor\Type\Types\MixedType;
use CuyZ\Valinor\Type\Types\UnresolvableType;
use WeakMap;
/** @internal */
final class ClassFormatter implements TypeFormatter
{
public function __construct(
private ClassDefinition $class,
) {}
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::this()->callMethod(
method: $this->methodName(),
arguments: [
$valueNode,
Node::variable('references'),
],
);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
$methodName = $this->methodName();
if ($class->hasMethod($methodName)) {
return $class;
}
// This is a placeholder method to avoid circular references coming from
// the class properties.
$class = $class->withMethods(Node::method($methodName));
$valuesNode = $this->valuesNode(Node::variable('value'));
$nodes = [
...$this->checkCircularObjectReference(),
Node::variable('values')->assign($valuesNode)->asExpression(),
];
$transformedNodes = [
Node::variable('transformed')->assign(Node::array())->asExpression(),
];
$shouldUseTransformedNodes = false;
foreach ($this->class->properties as $property) {
$propertyDefinition = $definitionBuilder->for($property->type);
if (! $property->nativeType instanceof MixedType) {
$propertyDefinition = $propertyDefinition->markAsSure();
}
if (! $property->type instanceof UnresolvableType) {
$propertyDefinition = $propertyDefinition->withTransformerAttributes(
$property->attributes
->filter(TransformerContainer::filterTransformerAttributes(...))
->filter(
static fn (AttributeDefinition $attribute): bool => $property->type->matches(
$attribute->class->methods->get('normalize')->parameters->at(0)->type,
),
)->toArray(),
);
}
$typeFormatter = $propertyDefinition->typeFormatter();
$keyTransformerAttributes = $property->attributes
->filter(TransformerContainer::filterKeyTransformerAttributes(...))
->toArray();
if ($keyTransformerAttributes === []) {
$key = Node::value($property->name);
} else {
$transformedNodes[] = Node::variable('key')->assign(Node::value($property->name))->asExpression();
foreach ($keyTransformerAttributes as $attribute) {
$transformedNodes[] = Node::variable('key')->assign(
(new NewAttributeNode($attribute))->wrap()->callMethod(
method: 'normalizeKey',
arguments: [Node::variable('key')],
),
)->asExpression();
}
$key = Node::variable('key');
}
$transformedNodes[] = Node::variable('transformed')
->key($key)
->assign($typeFormatter->formatValueNode(
Node::variable('values')->key(Node::value($property->name)),
))->asExpression();
$class = $typeFormatter->manipulateTransformerClass($class, $definitionBuilder);
$shouldUseTransformedNodes = $shouldUseTransformedNodes
|| $propertyDefinition->hasTransformers()
|| $keyTransformerAttributes !== []
|| $property->type instanceof UnresolvableType
|| ! $property->nativeType instanceof ScalarType;
}
if ($shouldUseTransformedNodes) {
$nodes = [
...$nodes,
...$transformedNodes,
Node::return(Node::variable('transformed'))
];
} else {
$nodes[] = Node::return(Node::variable('values'));
}
return $class->withMethods(
Node::method($methodName)
->witParameters(
Node::parameterDeclaration('value', str_contains($this->class->name, '@anonymous') ? 'object' : $this->class->name),
Node::parameterDeclaration('references', WeakMap::class),
)
->withReturnType('array')
->withBody(...$nodes),
);
}
/**
* @return list<Node>
*/
private function checkCircularObjectReference(): array
{
return [
Node::if(
condition: Node::functionCall('isset', [Node::variable('references')->key(Node::variable('value'))]),
body: Node::throw(
Node::newClass(CircularReferenceFoundDuringNormalization::class, Node::variable('value')),
)->asExpression(),
),
Node::variable('references')->assign(Node::variable('references')->clone())->asExpression(),
Node::variable('references')->key(Node::variable('value'))->assign(Node::variable('value'))->asExpression(),
];
}
/**
* This method returns a node responsible for getting the properties' values
* of an object.
*
* First scenario: if all properties are public, we can extract them easily
* by accessing the properties directly, resulting in a code like this:
*
* ```
* [
* 'someProperty' => $value->someProperty,
* 'anotherProperty' => $value->anotherProperty,
* ]
* ```
*
* Second scenario: at least one property is protected/private. In this
* case, we need to "extract" the properties using `get_object_vars`:
*
* ```
* (fn () => get_object_vars($this))->call($value)
* ```
*/
private function valuesNode(ComplianceNode $valueNode): Node
{
$allPropertiesArePublic = true;
$assignments = [];
foreach ($this->class->properties as $property) {
$assignments[$property->name] = $valueNode->access($property->name);
$allPropertiesArePublic = $allPropertiesArePublic && $property->isPublic;
}
if ($allPropertiesArePublic) {
return Node::array($assignments);
}
return Node::shortClosure(
return: Node::functionCall(
name: 'get_object_vars',
arguments: [Node::this()],
),
)->wrap()->callMethod('call', [$valueNode]);
}
/**
* @return non-empty-string
*/
private function methodName(): string
{
$slug = preg_replace('/[^a-z0-9]+/', '_', strtolower($this->class->type->toString()));
return "transform_object_{$slug}_" . hash('crc32', $this->class->type->toString());
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
/** @internal */
final class DateTimeFormatter implements TypeFormatter
{
public function formatValueNode(ComplianceNode $valueNode): Node
{
return $valueNode->callMethod('format', [
Node::value('Y-m-d\\TH:i:s.uP'), // RFC 3339
]);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
return $class;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
/** @internal */
final class DateTimeZoneFormatter implements TypeFormatter
{
public function formatValueNode(ComplianceNode $valueNode): Node
{
return $valueNode->callMethod('getName');
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
return $class;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use BackedEnum;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Type\Types\EnumType;
/** @internal */
final class EnumFormatter implements TypeFormatter
{
public function __construct(
private EnumType $type,
) {}
public function formatValueNode(ComplianceNode $valueNode): Node
{
return is_a($this->type->className(), BackedEnum::class, true)
? $valueNode->access('value')
: $valueNode->access('name');
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
return $class;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Type\Types\InterfaceType;
use DateTimeInterface;
/** @internal */
final class InterfaceFormatter implements TypeFormatter
{
public function __construct(
private InterfaceType $type,
) {}
public function formatValueNode(ComplianceNode $valueNode): Node
{
if ($this->type->className() === DateTimeInterface::class) {
return (new DateTimeFormatter())->formatValueNode($valueNode);
}
return Node::this()
->access('delegate')
->callMethod('transform', [$valueNode]);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
return $class;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Library\TypeAcceptNode;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Type\Types\IterableType;
use CuyZ\Valinor\Type\Types\NativeBooleanType;
use CuyZ\Valinor\Type\Types\NativeClassType;
use CuyZ\Valinor\Type\Types\NativeFloatType;
use CuyZ\Valinor\Type\Types\NativeIntegerType;
use CuyZ\Valinor\Type\Types\NativeStringType;
use CuyZ\Valinor\Type\Types\NullType;
use DateTime;
use DateTimeZone;
use UnitEnum;
use WeakMap;
/** @internal */
final class MixedFormatter implements TypeFormatter
{
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::this()->callMethod(
method: 'transform_mixed',
arguments: [
$valueNode,
Node::variable('references'),
],
);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
if ($class->hasMethod('transform_mixed')) {
return $class;
}
// This is a placeholder method to avoid infinite loops.
$class = $class->withMethods(Node::method('transform_mixed'));
$nodes = [];
$types = [
NativeBooleanType::get(),
NativeFloatType::get(),
NativeIntegerType::get(),
NativeStringType::get(),
NullType::get(),
new NativeClassType(UnitEnum::class),
new NativeClassType(DateTime::class),
new NativeClassType(DateTimeZone::class),
IterableType::native(),
];
foreach ($types as $type) {
$definition = $definitionBuilder->for($type)->markAsSure();
$class = $definition->typeFormatter()->manipulateTransformerClass($class, $definitionBuilder);
$nodes[] = Node::if(
condition: new TypeAcceptNode(Node::variable('value'), $definition->type),
body: Node::return($definition->typeFormatter()->formatValueNode(Node::variable('value'))),
);
}
$nodes[] = Node::return(
Node::this()
->access('delegate')
->callMethod('transform', [
Node::variable('value'),
]),
);
return $class->withMethods(
Node::method('transform_mixed')
->witParameters(
Node::parameterDeclaration('value', 'mixed'),
Node::parameterDeclaration('references', WeakMap::class),
)
->withReturnType('mixed')
->withBody(...$nodes),
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
/** @internal */
final class NullFormatter implements TypeFormatter
{
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::value(null);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
return $class;
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Library\NewAttributeNode;
use CuyZ\Valinor\Compiler\Library\TypeAcceptNode;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Definition\AttributeDefinition;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Type\Type;
use WeakMap;
/** @internal */
final class RegisteredTransformersFormatter implements TypeFormatter
{
public function __construct(
private Type $type,
private TypeFormatter $delegate,
/** @var array<int, Type> */
private array $transformerTypes,
/** @var list<AttributeDefinition> */
private array $transformerAttributes = [],
) {}
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::this()->callMethod(
method: $this->methodName(),
arguments: [
$valueNode,
Node::variable('references'),
],
);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
$methodName = $this->methodName();
if ($class->hasMethod($methodName)) {
return $class;
}
$class = $this->delegate->manipulateTransformerClass($class, $definitionBuilder);
$nodes = [
Node::variable('next')->assign(
Node::shortClosure(
$this->delegate->formatValueNode(Node::variable('value')),
),
)->asExpression(),
...array_map(
fn (int $key, Type $transformerType) => Node::if(
condition: new TypeAcceptNode(Node::variable('value'), $transformerType),
body: Node::variable('next')->assign(
Node::shortClosure(
return: Node::this()
->access('transformers')
->key(Node::value($key))
->call(arguments: [
Node::variable('value'),
Node::variable('next'),
]),
),
)->asExpression(),
),
array_keys($this->transformerTypes),
$this->transformerTypes,
),
...array_map(
fn (AttributeDefinition $attribute) => Node::variable('next')->assign(
Node::shortClosure(
return: (new NewAttributeNode($attribute))
->wrap()
->callMethod(
method: 'normalize',
arguments: [
Node::variable('value'),
Node::variable('next'),
],
),
),
)->asExpression(),
$this->transformerAttributes,
),
Node::return(
Node::variable('next')->call(),
),
];
return $class->withMethods(
Node::method($methodName)
->witParameters(
Node::parameterDeclaration('value', 'mixed'),
Node::parameterDeclaration('references', WeakMap::class),
)
->withReturnType('mixed')
->withBody(...$nodes),
);
}
/**
* @return non-empty-string
*/
private function methodName(): string
{
$slug = preg_replace('/[^a-z0-9]+/', '_', strtolower($this->type->toString()));
return "transform_{$slug}_" . sha1(serialize([$this->type->toString(), $this->transformerAttributes]));
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
/** @internal */
final class ScalarFormatter implements TypeFormatter
{
public function formatValueNode(ComplianceNode $valueNode): Node
{
return $valueNode;
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
return $class;
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Type\Types\ArrayType;
use CuyZ\Valinor\Type\Types\MixedType;
use CuyZ\Valinor\Type\Types\ShapedArrayType;
use WeakMap;
/** @internal */
final class ShapedArrayFormatter implements TypeFormatter
{
public function __construct(
private ShapedArrayType $type,
) {}
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::this()->callMethod(
method: $this->methodName(),
arguments: [
$valueNode,
Node::variable('references'),
],
);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
$methodName = $this->methodName();
if ($class->hasMethod($methodName)) {
return $class;
}
if ($this->type->isUnsealed && $this->type->unsealedType() instanceof ArrayType) {
$defaultDefinition = $definitionBuilder->for($this->type->unsealedType()->subType());
} else {
$defaultDefinition = $definitionBuilder->for(MixedType::get());
}
$class = $defaultDefinition->typeFormatter()->manipulateTransformerClass($class, $definitionBuilder);
$elementsDefinitions = [];
foreach ($this->type->elements as $key => $element) {
$elementDefinition = $definitionBuilder->for($element->type());
$class = $elementDefinition->typeFormatter()->manipulateTransformerClass($class, $definitionBuilder);
$elementsDefinitions[$key] = $elementDefinition;
}
return $class->withMethods(
Node::method($methodName)
->witParameters(
Node::parameterDeclaration('value', 'array'),
Node::parameterDeclaration('references', WeakMap::class),
)
->withReturnType('array')
->withBody(
Node::variable('result')->assign(Node::array())->asExpression(),
Node::forEach(
value: Node::variable('value'),
key: 'key',
item: 'item',
body: Node::variable('result')->key(Node::variable('key'))->assign(
(function () use ($defaultDefinition, $elementsDefinitions) {
$match = Node::match(Node::variable('key'));
foreach ($elementsDefinitions as $name => $definition) {
$match = $match->withCase(
condition: Node::value($name),
body: $definition->typeFormatter()->formatValueNode(Node::variable('item')),
);
}
return $match->withDefaultCase(
$defaultDefinition->typeFormatter()->formatValueNode(Node::variable('item')),
);
})(),
)->asExpression(),
),
Node::return(Node::variable('result')),
),
);
}
/**
* @return non-empty-string
*/
private function methodName(): string
{
return 'transform_shaped_array_' . hash('crc32', $this->type->toString());
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Normalizer\Transformer\EmptyObject;
use CuyZ\Valinor\Type\Types\MixedType;
use stdClass;
/** @internal */
final class StdClassFormatter implements TypeFormatter
{
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
if ($class->hasMethod('transform_stdclass')) {
return $class;
}
$defaultDefinition = $definitionBuilder->for(MixedType::get());
$class = $defaultDefinition->typeFormatter()->manipulateTransformerClass($class, $definitionBuilder);
return $class->withMethods(
Node::method('transform_stdclass')
->witParameters(
Node::parameterDeclaration('value', stdClass::class),
Node::parameterDeclaration('references', \WeakMap::class),
)
->withReturnType('mixed')
->withBody(
Node::variable('values')->assign(Node::variable('value')->castToArray())->asExpression(),
Node::if(
condition: Node::variable('values')->equals(Node::array()),
body: Node::return(
Node::class(EmptyObject::class)->callStaticMethod('get'),
),
),
Node::return(
Node::functionCall(
name: 'array_map',
arguments: [
Node::shortClosure(
return: $defaultDefinition->typeFormatter()->formatValueNode(Node::variable('value')),
)->witParameters(Node::parameterDeclaration('value', 'mixed')),
Node::variable('value')->castToArray(),
],
)
),
),
);
}
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::this()->callMethod(
method: 'transform_stdclass',
arguments: [
$valueNode,
Node::variable('references'),
],
);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Type\Type;
use WeakMap;
/** @internal */
final class TraversableFormatter implements TypeFormatter
{
public function __construct(
private Type $subType,
) {}
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::this()->callMethod(
method: $this->methodName(),
arguments: [
$valueNode,
Node::variable('references'),
],
);
}
/**
* If the input is an array, we use `array_map` to format all sub-values
* easily. If the input is not an array, we return a generator that will
* yield all transformed values one at a time.
*
* Generated code should look like:
*
* ```php
* if (is_array($value)) {
* return array_map(
* fn ($item) => $this->some_function($item),
* $value,
* );
* }
*
* return (function () use ($value) {
* foreach ($value as $key => $item) {
* yield $key => $this->some_function($item);
* }
* })();
* ```
*/
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
$methodName = $this->methodName();
if ($class->hasMethod($methodName)) {
return $class;
}
$subDefinition = $definitionBuilder->for($this->subType);
$class = $subDefinition->typeFormatter()->manipulateTransformerClass($class, $definitionBuilder);
return $class->withMethods(
Node::method($methodName)
->witParameters(
Node::parameterDeclaration('value', 'iterable'),
Node::parameterDeclaration('references', WeakMap::class),
)
->withReturnType('iterable')
->withBody(
Node::if(
condition: Node::functionCall('is_array', [Node::variable('value')]),
body: Node::return(
Node::functionCall(
name: 'array_map',
arguments: [
Node::shortClosure(
return: $subDefinition->typeFormatter()->formatValueNode(Node::variable('item')),
)->witParameters(Node::parameterDeclaration('item', 'mixed')),
Node::variable('value')
],
),
)
),
Node::return(
Node::closure(
Node::forEach(
value: Node::variable('value'),
key: 'key',
item: 'item',
body: Node::yield(
key: Node::variable('key'),
value: $subDefinition->typeFormatter()->formatValueNode(Node::variable('item')),
)->asExpression(),
)
)->uses('value', 'references')->wrap()->call(),
),
),
);
}
/**
* @return non-empty-string
*/
private function methodName(): string
{
$slug = preg_replace('/[^a-z0-9]+/', '_', strtolower($this->subType->toString()));
return "transform_iterable_{$slug}_" . hash('crc32', $this->subType->toString());
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
/** @internal */
interface TypeFormatter
{
public function formatValueNode(ComplianceNode $valueNode): Node;
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode;
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Library\TypeAcceptNode;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Type\Types\MixedType;
use CuyZ\Valinor\Type\Types\UnionType;
use WeakMap;
/** @internal */
final class UnionFormatter implements TypeFormatter
{
public function __construct(
private UnionType $type,
) {}
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::this()->callMethod(
method: $this->methodName(),
arguments: [
$valueNode,
Node::variable('references'),
],
);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
$methodName = $this->methodName();
if ($class->hasMethod($methodName)) {
return $class;
}
$defaultDefinition = $definitionBuilder->for(MixedType::get());
$class = $defaultDefinition->typeFormatter()->manipulateTransformerClass($class, $definitionBuilder);
$nodes = [];
foreach ($this->type->types() as $subType) {
$definition = $definitionBuilder->for($subType)->markAsSure();
$class = $definition->typeFormatter()->manipulateTransformerClass($class, $definitionBuilder);
$nodes[] = Node::if(
condition: new TypeAcceptNode(Node::variable('value'), $definition->type),
body: Node::return(
$definition->typeFormatter()->formatValueNode(Node::variable('value')),
),
);
}
$nodes[] = Node::return(
$defaultDefinition->typeFormatter()->formatValueNode(Node::variable('value')),
);
return $class->withMethods(
Node::method($methodName)
->witParameters(
Node::parameterDeclaration('value', 'mixed'),
Node::parameterDeclaration('references', WeakMap::class),
)
->withReturnType('mixed')
->withBody(...$nodes),
);
}
/**
* @return non-empty-string
*/
private function methodName(): string
{
return 'transform_union_' . hash('crc32', $this->type->toString());
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use BackedEnum;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
/** @internal */
final class UnitEnumFormatter implements TypeFormatter
{
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::ternary(
condition: $valueNode->instanceOf(BackedEnum::class),
ifTrue: $valueNode->access('value'),
ifFalse: $valueNode->access('name'),
);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
return $class;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer\Compiler\TypeFormatter;
use CuyZ\Valinor\Compiler\Library\TypeAcceptNode;
use CuyZ\Valinor\Compiler\Native\AnonymousClassNode;
use CuyZ\Valinor\Compiler\Native\ComplianceNode;
use CuyZ\Valinor\Compiler\Node;
use CuyZ\Valinor\Normalizer\Transformer\Compiler\TransformerDefinitionBuilder;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\MixedType;
/** @internal */
final class UnsureTypeFormatter implements TypeFormatter
{
public function __construct(
private TypeFormatter $delegate,
private Type $unsureType,
) {}
public function formatValueNode(ComplianceNode $valueNode): Node
{
return Node::this()->callMethod(
method: $this->methodName(),
arguments: [
$valueNode,
Node::variable('references'),
],
);
}
public function manipulateTransformerClass(AnonymousClassNode $class, TransformerDefinitionBuilder $definitionBuilder): AnonymousClassNode
{
$methodName = $this->methodName();
if ($class->hasMethod($methodName)) {
return $class;
}
$class = $this->delegate->manipulateTransformerClass($class, $definitionBuilder);
$defaultDefinition = $definitionBuilder->for(MixedType::get());
$class = $defaultDefinition->typeFormatter()->manipulateTransformerClass($class, $definitionBuilder);
return $class->withMethods(
Node::method($methodName)
->witParameters(
Node::parameterDeclaration('value', 'mixed'),
Node::parameterDeclaration('references', \WeakMap::class),
)
->withReturnType('mixed')
->withBody(
Node::if(
condition: Node::negate(
(new TypeAcceptNode(Node::variable('value'), $this->unsureType->nativeType()))->wrap(),
),
body: Node::return($defaultDefinition->typeFormatter()->formatValueNode(Node::variable('value'))),
),
Node::return(
$this->delegate->formatValueNode(Node::variable('value')),
),
),
);
}
/**
* @return non-empty-string
*/
private function methodName(): string
{
$slug = preg_replace('/[^a-z0-9]+/', '_', strtolower($this->unsureType->toString()));
return "transform_unsure_{$slug}_" . hash('crc32', $this->unsureType->toString());
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer;
use CuyZ\Valinor\Utility\IsSingleton;
/** @internal */
final class EmptyObject
{
use IsSingleton;
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer;
use BackedEnum;
use Closure;
use CuyZ\Valinor\Definition\AttributeDefinition;
use CuyZ\Valinor\Definition\Attributes;
use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository;
use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository;
use CuyZ\Valinor\Normalizer\Exception\CircularReferenceFoundDuringNormalization;
use CuyZ\Valinor\Normalizer\Exception\TypeUnhandledByNormalizer;
use CuyZ\Valinor\Type\Types\EnumType;
use CuyZ\Valinor\Type\Types\NativeClassType;
use DateTimeInterface;
use DateTimeZone;
use stdClass;
use UnitEnum;
use WeakMap;
use function array_map;
use function get_object_vars;
use function is_array;
use function is_iterable;
use function is_object;
/** @internal */
final class RecursiveTransformer implements Transformer
{
public function __construct(
private ClassDefinitionRepository $classDefinitionRepository,
private FunctionDefinitionRepository $functionDefinitionRepository,
private TransformerContainer $transformerContainer,
) {}
public function transform(mixed $value): mixed
{
return $this->doTransform($value, new WeakMap()); // @phpstan-ignore-line
}
/**
* @param WeakMap<object, object> $references
* @param list<AttributeDefinition> $attributes
*/
private function doTransform(mixed $value, WeakMap $references, array $attributes = []): mixed
{
if (is_object($value)) {
if (isset($references[$value])) {
throw new CircularReferenceFoundDuringNormalization($value);
}
$references = clone $references;
$references[$value] = $value;
$type = $value instanceof UnitEnum
? EnumType::native($value::class)
: new NativeClassType($value::class);
$classAttributes = $this->classDefinitionRepository->for($type)->attributes->toArray();
$attributes = [...$attributes, ...$classAttributes];
}
if (! $this->transformerContainer->hasTransformers() && $attributes === []) {
return $this->defaultTransformer($value, $references);
}
if ($attributes !== []) {
$attributes = (new Attributes(...$attributes))
->filter(TransformerContainer::filterTransformerAttributes(...))
->toArray();
}
$transformers = [
// First chunk of transformers to be used: attributes, coming from
// class or property.
...array_map(
fn (AttributeDefinition $attribute) => $attribute->instantiate()->normalize(...), // @phpstan-ignore-line / We know the method exists
$attributes,
),
// Second chunk of transformers to be used: registered transformers.
...$this->transformerContainer->transformers(),
// Last one: default transformer.
fn (mixed $value) => $this->defaultTransformer($value, $references),
];
return call_user_func($this->nextTransformer($transformers, $value));
}
/**
* @param non-empty-list<callable> $transformers
*/
private function nextTransformer(array $transformers, mixed $value): callable
{
$transformer = array_shift($transformers);
if ($transformers === []) {
return fn () => $transformer($value);
}
$function = $this->functionDefinitionRepository->for($transformer);
if (! $function->parameters->at(0)->type->accepts($value)) {
return $this->nextTransformer($transformers, $value);
}
return fn () => $transformer($value, fn () => call_user_func($this->nextTransformer($transformers, $value)));
}
/**
* @param WeakMap<object, object> $references
* @return iterable<mixed>|scalar|null
*/
private function defaultTransformer(mixed $value, WeakMap $references): mixed
{
if ($value === null) {
return null;
}
if (is_scalar($value)) {
return $value;
}
if (is_iterable($value)) {
if (is_array($value)) {
return array_map(
fn (mixed $value) => $this->doTransform($value, $references),
$value
);
}
return (function () use ($value, $references) {
foreach ($value as $key => $item) {
yield $key => $this->doTransform($item, $references);
}
})();
}
if (is_object($value) && ! $value instanceof Closure) {
if ($value instanceof UnitEnum) {
return $value instanceof BackedEnum ? $value->value : $value->name;
}
if ($value instanceof DateTimeInterface) {
return $value->format('Y-m-d\\TH:i:s.uP'); // RFC 3339
}
if ($value instanceof DateTimeZone) {
return $value->getName();
}
if ($value::class === stdClass::class) {
$result = (array)$value;
if ($result === []) {
return EmptyObject::get();
}
return array_map(
fn (mixed $value) => $this->doTransform($value, $references),
$result,
);
}
$values = (fn () => get_object_vars($this))->call($value);
$transformed = [];
$class = $this->classDefinitionRepository->for(new NativeClassType($value::class));
foreach ($values as $key => $subValue) {
$property = $class->properties->get($key);
$keyTransformersAttributes = $property->attributes->filter(TransformerContainer::filterKeyTransformerAttributes(...));
foreach ($keyTransformersAttributes as $attribute) {
$method = $attribute->class->methods->get('normalizeKey');
if ($method->parameters->count() === 0 || $method->parameters->at(0)->type->accepts($key)) {
/** @var string $key */
$key = $attribute->instantiate()->normalizeKey($key); // @phpstan-ignore-line / We know the method exists
}
}
$transformed[$key] = $this->doTransform($subValue, $references, $property->attributes->toArray());
}
return $transformed;
}
throw new TypeUnhandledByNormalizer($value);
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer;
/** @internal */
interface Transformer
{
public function transform(mixed $value): mixed;
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Normalizer\Transformer;
use CuyZ\Valinor\Definition\AttributeDefinition;
use CuyZ\Valinor\Definition\FunctionDefinition;
use CuyZ\Valinor\Definition\MethodDefinition;
use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository;
use CuyZ\Valinor\Normalizer\Exception\KeyTransformerHasTooManyParameters;
use CuyZ\Valinor\Normalizer\Exception\KeyTransformerParameterInvalidType;
use CuyZ\Valinor\Normalizer\Exception\TransformerHasInvalidCallableParameter;
use CuyZ\Valinor\Normalizer\Exception\TransformerHasNoParameter;
use CuyZ\Valinor\Normalizer\Exception\TransformerHasTooManyParameters;
use CuyZ\Valinor\Type\StringType;
use CuyZ\Valinor\Type\Types\CallableType;
/** @internal */
final class TransformerContainer
{
private bool $transformersCallablesWereChecked = false;
public function __construct(
private FunctionDefinitionRepository $functionDefinitionRepository,
/** @var list<callable> */
private array $transformers,
) {}
public function hasTransformers(): bool
{
return $this->transformers !== [];
}
/**
* @return list<callable>
*/
public function transformers(): array
{
if (! $this->transformersCallablesWereChecked) {
$this->transformersCallablesWereChecked = true;
foreach ($this->transformers as $transformer) {
$function = $this->functionDefinitionRepository->for($transformer);
self::checkTransformer($function);
}
}
return $this->transformers;
}
public static function filterTransformerAttributes(AttributeDefinition $attribute): bool
{
return $attribute->class->methods->has('normalize')
&& self::checkTransformer($attribute->class->methods->get('normalize'));
}
public static function filterKeyTransformerAttributes(AttributeDefinition $attribute): bool
{
return $attribute->class->methods->has('normalizeKey')
&& self::checkKeyTransformer($attribute->class->methods->get('normalizeKey'));
}
private static function checkTransformer(MethodDefinition|FunctionDefinition $method): bool
{
$parameters = $method->parameters;
if ($parameters->count() === 0) {
throw new TransformerHasNoParameter($method);
}
if ($parameters->count() > 2) {
throw new TransformerHasTooManyParameters($method);
}
if ($parameters->count() > 1 && ! $parameters->at(1)->nativeType instanceof CallableType) {
throw new TransformerHasInvalidCallableParameter($method, $parameters->at(1)->nativeType);
}
return true;
}
private static function checkKeyTransformer(MethodDefinition $method): bool
{
$parameters = $method->parameters;
if ($parameters->count() > 1) {
throw new KeyTransformerHasTooManyParameters($method);
}
if ($parameters->count() > 0) {
$type = $parameters->at(0)->type;
if (! $type instanceof StringType) {
throw new KeyTransformerParameterInvalidType($method);
}
}
return true;
}
}