Skip to content
31 changes: 19 additions & 12 deletions src/DataPipes/CastPropertiesDataPipe.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\DataProperty;
use Spatie\LaravelData\Support\Types\CombinationType;
use Spatie\LaravelData\Support\Types\UnionType;

class CastPropertiesDataPipe implements DataPipe
{
Expand Down Expand Up @@ -222,24 +223,30 @@ protected function findCastForIterableItems(
): ?IterableItemCast {
$firstItem = $values[array_key_first($values)];

foreach ($creationContext->casts?->findCastsForIterableType($property->type->iterableItemType) ?? [] as $possibleCast) {
$casted = $possibleCast->castIterableItem($property, $firstItem, $properties, $creationContext);
$iterableItemTypes = $property->type->type instanceof UnionType
? array_column($property->type->type->types, 'iterableItemType')
: [$property->type->iterableItemType];

if (! $casted instanceof Uncastable) {
return $possibleCast;
foreach ($iterableItemTypes as $iterableItemType) {
foreach ($creationContext->casts?->findCastsForIterableType($iterableItemType) ?? [] as $possibleCast) {
$casted = $possibleCast->castIterableItem($property, $firstItem, $properties, $creationContext);

if (! $casted instanceof Uncastable) {
return $possibleCast;
}
}
}

foreach ($this->dataConfig->casts->findCastsForIterableType($property->type->iterableItemType) as $possibleCast) {
$casted = $possibleCast->castIterableItem($property, $firstItem, $properties, $creationContext);
foreach ($this->dataConfig->casts->findCastsForIterableType($iterableItemType) as $possibleCast) {
$casted = $possibleCast->castIterableItem($property, $firstItem, $properties, $creationContext);

if (! $casted instanceof Uncastable) {
return $possibleCast;
if (! $casted instanceof Uncastable) {
return $possibleCast;
}
}
}

if (in_array($property->type->iterableItemType, ['bool', 'int', 'float', 'array', 'string'])) {
return new BuiltinTypeCast($property->type->iterableItemType);
if (in_array($iterableItemType, ['bool', 'int', 'float', 'array', 'string'])) {
return new BuiltinTypeCast($iterableItemType);
}
}

return null;
Expand Down
244 changes: 103 additions & 141 deletions src/Support/Annotations/DataIterableAnnotationReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@

namespace Spatie\LaravelData\Support\Annotations;

use Illuminate\Support\Arr;
use phpDocumentor\Reflection\FqsenResolver;
use phpDocumentor\Reflection\Type;
use phpDocumentor\Reflection\TypeResolver;
use phpDocumentor\Reflection\Types\AbstractList;
use phpDocumentor\Reflection\Types\Compound;
use phpDocumentor\Reflection\Types\Nullable;
use ReflectionClass;
use ReflectionMethod;
use ReflectionProperty;
use Spatie\LaravelData\Contracts\BaseData;
use Spatie\LaravelData\Lazy;
use Spatie\LaravelData\Optional;
use Spatie\LaravelData\Resolvers\ContextResolver;
use Spatie\LaravelData\Support\Annotations\PhpDocumentorTypes\ArrayWithoutMixedDefault;
use Spatie\LaravelData\Support\Annotations\PhpDocumentorTypes\IterableWithoutMixedDefault;

/**
* @note To myself, always use the fully qualified class names in pest tests when using anonymous classes
Expand All @@ -20,177 +28,131 @@ public function __construct(
) {
}

/** @return array<string, DataIterableAnnotation> */
/** @return array<string, DataIterableAnnotation[]> */
public function getForClass(ReflectionClass $class): array
{
return collect($this->get($class))->keyBy(fn (DataIterableAnnotation $annotation) => $annotation->property)->all();
return collect($this->get($class))
->groupBy(fn (DataIterableAnnotation $annotation) => $annotation->property)
->toArray();
}

public function getForProperty(ReflectionProperty $property): ?DataIterableAnnotation
/** @return DataIterableAnnotation[] */
public function getForProperty(ReflectionProperty $property): array
{
return Arr::first($this->get($property));
return $this->get($property);
}

/** @return array<string, DataIterableAnnotation> */
/** @return array<string, DataIterableAnnotation[]> */
public function getForMethod(ReflectionMethod $method): array
{
return collect($this->get($method))->keyBy(fn (DataIterableAnnotation $annotation) => $annotation->property)->all();
return collect($this->get($method))
->groupBy(fn (DataIterableAnnotation $annotation) => $annotation->property)
->toArray();
}

/** @return DataIterableAnnotation[] */
protected function get(
ReflectionProperty|ReflectionClass|ReflectionMethod $reflection
): array {
$comment = $reflection->getDocComment();

if ($comment === false) {
return [];
}

$comment = str_replace('?', '', $comment);

$kindPattern = '(?:@property|@var|@param)\s*';
$fqsenPattern = '[\\\\\\p{L}0-9_\|]+';
$typesPattern = '[\\\\\\p{L}0-9_\\|\\[\\]]+';
$keyPattern = '(?<key>int|string|int\|string|string\|int|array-key)';
$parameterPattern = '\s*\$?(?<parameter>[\\p{L}0-9_]+)?';

preg_match_all(
"/{$kindPattern}(?<types>{$typesPattern}){$parameterPattern}/ui",
$comment,
$arrayMatches,
);

preg_match_all(
"/{$kindPattern}(?<collectionClass>{$fqsenPattern})<(?:{$keyPattern}\s*?,\s*?)?(?<dataClass>{$fqsenPattern})>(?:{$typesPattern})*{$parameterPattern}/ui",
$comment,
$collectionMatches,
);

return [
...$this->resolveArrayAnnotations($reflection, $arrayMatches),
...$this->resolveCollectionAnnotations($reflection, $collectionMatches),
];
}

protected function resolveArrayAnnotations(
ReflectionProperty|ReflectionClass|ReflectionMethod $reflection,
array $arrayMatches
): array {
$annotations = [];

foreach ($arrayMatches['types'] as $index => $types) {
$parameter = $arrayMatches['parameter'][$index];

$arrayType = Arr::first(
explode('|', $types),
fn (string $type) => str_contains($type, '[]'),
);

if (empty($arrayType)) {
continue;
}

$resolvedTuple = $this->resolveDataClass(
$reflection,
str_replace('[]', '', $arrayType)
);

$annotations[] = new DataIterableAnnotation(
type: $resolvedTuple['type'],
isData: $resolvedTuple['isData'],
property: empty($parameter) ? null : $parameter
);
$hasType = preg_match_all('/(?:@var|@param|@property(?:-read)?)(.+)/uim', $comment, $matches);
if (! $hasType) {
return [];
}

return $annotations;
}

protected function resolveCollectionAnnotations(
ReflectionProperty|ReflectionClass|ReflectionMethod $reflection,
array $collectionMatches
): array {
$annotations = [];
foreach ($matches[1] as $match) {
[$valueTypeString, $propertyName] = explode('$', $match, 2) + [1 => null];
// See https://www.php.net/manual/en/language.variables.basics.php
empty($propertyName) or $propertyName = preg_split('/[^a-z0-9_]/i', $propertyName, 2)[0];

$type = tap(new TypeResolver(), function (TypeResolver $t) {
$t->addKeyword('array', ArrayWithoutMixedDefault::class);
$t->addKeyword('iterable', IterableWithoutMixedDefault::class);
})->resolve($valueTypeString);

$getKeyTypeWithoutDefault = static function (AbstractList $type): ?Type {
return(new class ($type) extends AbstractList {
public function __construct(AbstractList $victim)
{
parent::__construct($victim->valueType, $victim->keyType);
}
public function getKeyTypeWithoutDefault(): ?Type
{
return $this->keyType;
}
})->getKeyTypeWithoutDefault();
};

/** @return string[] */
$getValueTypeStrings = static function (Type $type, ?Type $key) use ($getKeyTypeWithoutDefault, &$getValueTypeStrings): array {
if ($type instanceof Compound) {
return array_merge(...array_map(fn (Type $t) => $getValueTypeStrings($t, $key), iterator_to_array($type)));
} elseif ($type instanceof ArrayWithoutMixedDefault || $type instanceof IterableWithoutMixedDefault) {
return $type->getOriginalValueType() === null
? [[(string) $type, $key === null ? 'array-key' : (string) $key]]
: $getValueTypeStrings($type->getOriginalValueType(), $getKeyTypeWithoutDefault($type));
} elseif ($type instanceof AbstractList) {
return $getValueTypeStrings($type->getValueType(), $getKeyTypeWithoutDefault($type));
} elseif ($type instanceof Nullable) {
return $getValueTypeStrings($type->getActualType(), $key);
} else {
return [[(string) $type, $key === null ? 'array-key' : (string) $key]];
}
};

$valueTypeStrings = $getValueTypeStrings($type, null);
foreach ($valueTypeStrings as [$valueTypeString, $keyString]) {
$valueTypeString = ltrim($valueTypeString, '\\');
if (is_subclass_of($valueTypeString, BaseData::class)) {
$annotations[] = new DataIterableAnnotation(
type: $valueTypeString,
isData: true,
keyType: $keyString,
property: $propertyName,
);

continue;
}

foreach ($collectionMatches['dataClass'] as $index => $dataClass) {
$parameter = $collectionMatches['parameter'][$index];
$key = $collectionMatches['key'][$index];

$resolvedTuple = $this->resolveDataClass($reflection, $dataClass);

$annotations[] = new DataIterableAnnotation(
type: $resolvedTuple['type'],
isData: $resolvedTuple['isData'],
keyType: empty($key) ? 'array-key' : $key,
property: empty($parameter) ? null : $parameter
);
}

return $annotations;
}

/**
* @return array{type: string, isData: bool}
*/
protected function resolveDataClass(
ReflectionProperty|ReflectionClass|ReflectionMethod $reflection,
string $class
): array {
if (str_contains($class, '|')) {
$possibleNonDataType = null;

foreach (explode('|', $class) as $explodedClass) {
$resolvedTuple = $this->resolveDataClass($reflection, $explodedClass);
static $ignoredClasses = [Lazy::class, Optional::class];

if ($resolvedTuple['isData']) {
return $resolvedTuple;
try {
$fcqn = $this->resolveFcqn($reflection, $valueTypeString);
} catch (\InvalidArgumentException $e) {
$fcqn = null;
}
if (class_exists($fcqn)) {
if (! in_array($fcqn, $ignoredClasses) && ! array_any($ignoredClasses, fn ($ignoredClass) => is_subclass_of($fcqn, $ignoredClass))) {
$annotations[] = new DataIterableAnnotation(
type: $fcqn,
isData: is_subclass_of($fcqn, BaseData::class),
keyType: $keyString,
property: $propertyName,
);
}

continue;
}

$possibleNonDataType = $resolvedTuple['type'];
if (! in_array($valueTypeString, $ignoredClasses) && ! array_any($ignoredClasses, fn ($ignoredClass) => is_subclass_of($valueTypeString, $ignoredClass))) {
$annotations[] = new DataIterableAnnotation(
type: $valueTypeString,
isData: false,
keyType: $keyString,
property: $propertyName,
);
}
}

return [
'type' => $possibleNonDataType,
'isData' => false,
];
}

if (in_array($class, ['int', 'string', 'bool', 'float', 'array', 'object', 'callable', 'iterable', 'mixed'])) {
return [
'type' => $class,
'isData' => false,
];
}

$class = ltrim($class, '\\');

if (is_subclass_of($class, BaseData::class)) {
return [
'type' => $class,
'isData' => true,
];
}

$fcqn = $this->resolveFcqn($reflection, $class);
usort($annotations, fn (DataIterableAnnotation $a, DataIterableAnnotation $b) => $b->isData <=> $a->isData);

if (is_subclass_of($fcqn, BaseData::class)) {
return [
'type' => $fcqn,
'isData' => true,
];
}

if (class_exists($fcqn)) {
return [
'type' => $fcqn,
'isData' => false,
];
}

return [
'type' => $class,
'isData' => false,
];
return $annotations;
}

protected function resolveFcqn(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Spatie\LaravelData\Support\Annotations\PhpDocumentorTypes;

use phpDocumentor\Reflection\Type;
use phpDocumentor\Reflection\Types\Array_;

class ArrayWithoutMixedDefault extends Array_
{
/** @var Type|null */
protected $originalValueType;

/**
* Initializes this representation of an array with the given Type.
*/
public function __construct(?Type $valueType = null, ?Type $keyType = null)
{
parent::__construct($valueType, $keyType);
$this->originalValueType = $valueType;
}

public function getOriginalValueType(): ?Type
{
return $this->originalValueType;
}
}
Loading