Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
238 changes: 98 additions & 140 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,127 @@ 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;
}
static $ignoredClasses = [Lazy::class, Optional::class];

/**
* @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);
$fcqn = $this->resolveFcqn($reflection, $valueTypeString);
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,
);
}

if ($resolvedTuple['isData']) {
return $resolvedTuple;
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