Skip to content

Commit

Permalink
Add support for true, false and null parameter type
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Nov 12, 2023
1 parent 16da657 commit 339c89e
Show file tree
Hide file tree
Showing 10 changed files with 96 additions and 55 deletions.
57 changes: 38 additions & 19 deletions src/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use ReflectionNamedType;
use ReflectionProperty;
use ReflectionType;
use ReflectionUnionType;
use Throwable;

use function array_reduce;
Expand Down Expand Up @@ -156,17 +157,19 @@ private function findPropertySetters(array $propertyNames): array
$propertySetters = [];
foreach ($this->class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
if ($property->isStatic()) {
//the property can not be cast yet
//as casting may be set using the Cell attribute
continue;
}

$propertyName = $property->getName();
$attribute = $property->getAttributes(Cell::class, ReflectionAttribute::IS_INSTANCEOF);
if ([] !== $attribute) {
//the property can not be cast yet
//as casting will be set using the Cell attribute
continue;
}

$propertyName = $property->getName();
/** @var int|false $offset */
$offset = array_search($propertyName, $propertyNames, true);
if (false === $offset) {
Expand All @@ -183,13 +186,13 @@ private function findPropertySetters(array $propertyNames): array

$cast = $this->resolveTypeCasting($type);
if (null === $cast) {
throw new MappingFailed('No valid type casting was found for property `'.$propertyName.'`.');
throw new MappingFailed('No valid type casting for `'.$type.'` was found for property `'.$propertyName.'`.');
}

$propertySetters[] = new PropertySetter($property, $offset, $cast);
}

$propertySetters = [...$propertySetters, ...$this->findPropertySettersByCellAttributes($propertyNames)];
$propertySetters = [...$propertySetters, ...$this->findPropertySettersByCellAttribute($propertyNames)];

//if converters is empty it means the Serializer
//was unable to detect properties to assign
Expand All @@ -205,7 +208,7 @@ private function findPropertySetters(array $propertyNames): array
*
* @return array<PropertySetter>
*/
private function findPropertySettersByCellAttributes(array $propertyNames): array
private function findPropertySettersByCellAttribute(array $propertyNames): array
{
$addPropertySetter = function (array $carry, ReflectionProperty|ReflectionMethod $accessor) use ($propertyNames) {
$propertySetter = $this->findPropertySetter($accessor, $propertyNames);
Expand Down Expand Up @@ -252,7 +255,7 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso
if (is_int($offset)) {
return match (true) {
0 > $offset => throw new MappingFailed('column integer position can only be positive or equals to 0; received `'.$offset.'`'),
[] !== $propertyNames && $offset > count($propertyNames) - 1 => throw new MappingFailed('column integer position can not exceed header cell count.'),
[] !== $propertyNames && $offset > count($propertyNames) - 1 => throw new MappingFailed('column integer position can not exceed property names count.'),
default => new PropertySetter($accessor, $offset, $cast),
};
}
Expand All @@ -277,22 +280,17 @@ private function findPropertySetter(ReflectionProperty|ReflectionMethod $accesso
*/
private function resolveTypeCasting(ReflectionType $reflectionType, array $arguments = []): ?TypeCasting
{
$type = '';
if ($reflectionType instanceof ReflectionNamedType) {
$type = $reflectionType->getName();
}
$type = (string) $this->getAccessorType($reflectionType);

try {
return match (Type::tryFromPropertyType($type)) {
Type::Mixed,
Type::String => new CastToString($type, ...$arguments), /* @phpstan-ignore-line */
Type::Iterable,
Type::Array => new CastToArray($type, ...$arguments), /* @phpstan-ignore-line */
Type::Float => new CastToFloat($type, ...$arguments), /* @phpstan-ignore-line */
Type::Int => new CastToInt($type, ...$arguments), /* @phpstan-ignore-line */
Type::Bool => new CastToBool($type, ...$arguments), /* @phpstan-ignore-line */
Type::Date => new CastToDate($type, ...$arguments), /* @phpstan-ignore-line */
Type::Enum => new CastToEnum($type, ...$arguments), /* @phpstan-ignore-line */
Type::Mixed, Type::Null, Type::String => new CastToString($type, ...$arguments), /* @phpstan-ignore-line */
Type::Iterable, Type::Array => new CastToArray($type, ...$arguments), /* @phpstan-ignore-line */
Type::False, Type::True, Type::Bool => new CastToBool($type, ...$arguments), /* @phpstan-ignore-line */
Type::Float => new CastToFloat($type, ...$arguments), /* @phpstan-ignore-line */
Type::Int => new CastToInt($type, ...$arguments), /* @phpstan-ignore-line */
Type::Date => new CastToDate($type, ...$arguments), /* @phpstan-ignore-line */
Type::Enum => new CastToEnum($type, ...$arguments), /* @phpstan-ignore-line */
null => null,
};
} catch (Throwable $exception) {
Expand Down Expand Up @@ -324,7 +322,7 @@ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod
throw new MappingFailed('The class `'.$typeCaster.'` does not implements the `'.TypeCasting::class.'` interface.');
}

$arguments = [...$cell->castArguments, ...['propertyType' => (string) $type]];
$arguments = [...$cell->castArguments, ...['propertyType' => (string) $this->getAccessorType($type)]];
/** @var TypeCasting $cast */
$cast = new $typeCaster(...$arguments);

Expand All @@ -343,4 +341,25 @@ private function getTypeCasting(Cell $cell, ReflectionProperty|ReflectionMethod
$accessor instanceof ReflectionProperty => 'No valid type casting was found for the property `'.$accessor->getName().'` must be typed.',
});
}

private function getAccessorType(?ReflectionType $type): ?string
{
return match (true) {
null === $type => null,
$type instanceof ReflectionNamedType => $type->getName(),
$type instanceof ReflectionUnionType => implode('|', array_reduce(
$type->getTypes(),
function (array $carry, ReflectionType $type): array {
$result = $this->getAccessorType($type);

return match ('') {
$result => $carry,
default => [...$carry, $result],
};
},
[]
)),
default => '',
};
}
}
2 changes: 1 addition & 1 deletion src/Serializer/CastToArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ final class CastToArray implements TypeCasting
public function __construct(
string $propertyType,
private readonly ?array $default = null,
ArrayShape|string $shape = 'list',
ArrayShape|string $shape = ArrayShape::List,
private readonly string $delimiter = ',',
private readonly string $enclosure = '"',
private readonly int $jsonDepth = 512,
Expand Down
16 changes: 12 additions & 4 deletions src/Serializer/CastToBool.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,36 @@
final class CastToBool implements TypeCasting
{
private readonly bool $isNullable;
private readonly Type $type;

public function __construct(
string $propertyType,
private readonly ?bool $default = null
) {
$baseType = Type::tryFromPropertyType($propertyType);
if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Bool)) {
$type = Type::tryFromPropertyType($propertyType);
if (null === $type || !$type->isOneOf(Type::Mixed, Type::Bool, Type::True, Type::False)) {
throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `bool` type is required.');
}

$this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?');
$this->type = $type;
$this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?');
}

/**
* @throws TypeCastingFailed
*/
public function toVariable(?string $value): ?bool
{
return match(true) {
$returnValue = match(true) {
null !== $value => filter_var($value, Type::Bool->filterFlag()),
$this->isNullable => $this->default,
default => throw new TypeCastingFailed('The `null` value can not be cast to a boolean value.'),
};

return match (true) {
Type::True->equals($this->type) && true !== $returnValue && !$this->isNullable,
Type::False->equals($this->type) && false !== $returnValue && !$this->isNullable => throw new TypeCastingFailed('The value `'.$value.'` could not be cast to `'.$this->type->value.'`.'),
default => $returnValue,
};
}
}
8 changes: 4 additions & 4 deletions src/Serializer/CastToDate.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,18 @@ public function __construct(
private readonly ?string $format = null,
DateTimeZone|string|null $timezone = null,
) {
$baseType = Type::tryFromPropertyType($propertyType);
if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Date)) {
$type = Type::tryFromPropertyType($propertyType);
if (null === $type || !$type->isOneOf(Type::Mixed, Type::Date)) {
throw new MappingFailed('The property type `'.$propertyType.'` is not supported; an class implementing the `'.DateTimeInterface::class.'` interface is required.');
}

$class = ltrim($propertyType, '?');
if (Type::Mixed->equals($baseType) || DateTimeInterface::class === $class) {
if (Type::Mixed->equals($type) || DateTimeInterface::class === $class) {
$class = DateTimeImmutable::class;
}

$this->class = $class;
$this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?');
$this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?');
try {
$this->timezone = is_string($timezone) ? new DateTimeZone($timezone) : $timezone;
$this->default = (null !== $default) ? $this->cast($default) : $default;
Expand Down
6 changes: 3 additions & 3 deletions src/Serializer/CastToEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ public function __construct(
?string $default = null,
?string $enum = null,
) {
$baseType = Type::tryFromPropertyType($propertyType);
if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Enum)) {
$type = Type::tryFromPropertyType($propertyType);
if (null === $type || !$type->isOneOf(Type::Mixed, Type::Enum)) {
throw new MappingFailed('The property type `'.$propertyType.'` is not supported; an `Enum` is required.');
}

$class = ltrim($propertyType, '?');
$isNullable = str_starts_with($propertyType, '?');
if ($baseType->equals(Type::Mixed)) {
if ($type->equals(Type::Mixed)) {
if (null === $enum || !enum_exists($enum)) {
throw new MappingFailed('You need to specify the enum class with a `mixed` typed property.');
}
Expand Down
6 changes: 3 additions & 3 deletions src/Serializer/CastToFloat.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ public function __construct(
string $propertyType,
private readonly ?float $default = null,
) {
$baseType = Type::tryFromPropertyType($propertyType);
if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Float)) {
$type = Type::tryFromPropertyType($propertyType);
if (null === $type || !$type->isOneOf(Type::Mixed, Type::Float)) {
throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `float` type is required.');
}

$this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?');
$this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?');
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/Serializer/CastToInt.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ public function __construct(
string $propertyType,
private readonly ?int $default = null,
) {
$baseType = Type::tryFromPropertyType($propertyType);
if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::Int, Type::Float)) {
$type = Type::tryFromPropertyType($propertyType);
if (null === $type || !$type->isOneOf(Type::Mixed, Type::Int, Type::Float)) {
throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `int` type is required.');
}

$this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?');
$this->isNullable = Type::Mixed->equals($type) || str_starts_with($propertyType, '?');
}

/**
Expand Down
17 changes: 12 additions & 5 deletions src/Serializer/CastToString.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,35 @@
final class CastToString implements TypeCasting
{
private readonly bool $isNullable;
private readonly Type $type;

public function __construct(
string $propertyType,
private readonly ?string $default = null
) {
$baseType = Type::tryFromPropertyType($propertyType);
if (null === $baseType || !$baseType->isOneOf(Type::Mixed, Type::String)) {
throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `string` type is required.');
$type = Type::tryFromPropertyType($propertyType);
if (null === $type || !$type->isOneOf(Type::Mixed, Type::String, Type::Null)) {
throw new MappingFailed('The property type `'.$propertyType.'` is not supported; a `string` or `null` type is required.');
}

$this->isNullable = $baseType->equals(Type::Mixed) || str_starts_with($propertyType, '?');
$this->type = $type;
$this->isNullable = $type->isOneOf(Type::Mixed, Type::Null) || str_starts_with($propertyType, '?');
}

/**
* @throws TypeCastingFailed
*/
public function toVariable(?string $value): ?string
{
return match(true) {
$returnedValue = match(true) {
null !== $value => $value,
$this->isNullable => $this->default,
default => throw new TypeCastingFailed('The `null` value can not be cast to a string.'),
};

return match (true) {
Type::Null->equals($this->type) && null !== $returnedValue => throw new TypeCastingFailed('The value `'.$value.'` could not be cast to `'.$this->type->value.'`.'),
default => $returnedValue,
};
}
}
15 changes: 11 additions & 4 deletions src/Serializer/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
enum Type: string
{
case Bool = 'bool';
case True = 'true';
case False = 'false';
case Null = 'null';
case Int = 'int';
case Float = 'float';
case String = 'string';
Expand All @@ -50,11 +53,11 @@ public function isOneOf(self ...$types): bool

public static function tryFromPropertyType(string $propertyType): ?self
{
$type = ltrim($propertyType, '?');
$basicType = self::tryFrom($type);
$type = ltrim($propertyType, '?');
$enumType = self::tryFrom($type);

return match (true) {
$basicType instanceof self => $basicType,
$enumType instanceof self => $enumType,
enum_exists($type) => self::Enum,
interface_exists($type) && DateTimeInterface::class === $type,
class_exists($type) && in_array(DateTimeInterface::class, class_implements($type), true) => self::Date,
Expand All @@ -65,7 +68,9 @@ class_exists($type) && in_array(DateTimeInterface::class, class_implements($type
public function filterFlag(): int
{
return match ($this) {
self::Bool => FILTER_VALIDATE_BOOL,
self::Bool,
self::True,
self::False => FILTER_VALIDATE_BOOL,
self::Int => FILTER_VALIDATE_INT,
self::Float => FILTER_VALIDATE_FLOAT,
default => FILTER_UNSAFE_RAW,
Expand All @@ -76,6 +81,8 @@ public function isScalar(): bool
{
return match ($this) {
self::Bool,
self::True,
self::False,
self::Int,
self::Float,
self::String => true,
Expand Down
18 changes: 9 additions & 9 deletions src/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ public function testItConvertsAnIterableListOfRecords(): void
],
];

$serializer = new Serializer(WeatherWithRecordAttribute::class, ['date', 'temperature', 'place']);
$results = [...$serializer->deserializeAll($records)];
$results = [...Serializer::assignAll(WeatherWithRecordAttribute::class, $records, ['date', 'temperature', 'place'])];
self::assertCount(2, $results);
foreach ($results as $result) {
self::assertInstanceOf(WeatherWithRecordAttribute::class, $result);
Expand Down Expand Up @@ -97,7 +96,7 @@ public function testItConvertsARecordsToAnObjectUsingMethods(): void
self::assertSame(-1.5, $weather->getTemperature());
}

public function testMapingFailBecauseTheRecordAttributeIsMissing(): void
public function testMappingFailBecauseTheRecordAttributeIsMissing(): void
{
$this->expectException(MappingFailed::class);
$this->expectExceptionMessage('No properties or method setters were found eligible on the class `stdClass` to be used for type casting.');
Expand Down Expand Up @@ -158,18 +157,20 @@ public function testItWillThrowBecauseTheObjectDoesNotHaveTypedProperties(): voi
public function testItWillFailForLackOfTypeCasting(): void
{
$this->expectException(MappingFailed::class);
$this->expectExceptionMessage('No valid type casting was found for property `observedOn`.');
$this->expectExceptionMessage('No valid type casting for `SplFileObject` was found for property `observedOn`');

new Serializer(InvaliDWeatherWithRecordAttributeAndUnknownCasting::class, ['temperature', 'place', 'observedOn']);
}

public function testItWillThrowIfTheClassContainsUnitiliaziedProperties(): void
public function testItWillThrowIfTheClassContainsUninitializedProperties(): void
{
$this->expectException(MappingFailed::class);
$this->expectExceptionMessage('No valid type casting was found for the property `annee` must be typed.');

$serializer = new Serializer(InvalidObjectWithUninitializedProperty::class, ['prenoms', 'nombre', 'sexe', 'annee']);
$serializer->deserialize(['prenoms' => 'John', 'nombre' => 42, 'sexe' => 'M', 'annee' => '2018']);
Serializer::assign(
InvalidObjectWithUninitializedProperty::class,
['prenoms' => 'John', 'nombre' => '42', 'sexe' => 'M', 'annee' => '2018']
);
}
}

Expand Down Expand Up @@ -267,8 +268,7 @@ public function getObservedOn(): DateTime
final class InvalidWeatherAttributeUsage
{
public function __construct(
/* @phpstan-ignore-next-line */
#[Cell(offset:'temperature'), Cell(offset:'date')]
#[Cell(offset:'temperature'), Cell(offset:'date')] /* @phpstan-ignore-line */
public readonly float $temperature,
#[Cell(offset:2, cast: CastToEnum::class)]
public readonly Place $place,
Expand Down

0 comments on commit 339c89e

Please sign in to comment.