diff --git a/src/DataPipes/CastPropertiesDataPipe.php b/src/DataPipes/CastPropertiesDataPipe.php index d3cbbb5e7..b2eea8b55 100644 --- a/src/DataPipes/CastPropertiesDataPipe.php +++ b/src/DataPipes/CastPropertiesDataPipe.php @@ -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 { @@ -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; diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index 1e406524f..8816d40c5 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -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 @@ -20,21 +28,26 @@ public function __construct( ) { } - /** @return array */ + /** @return array */ 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 */ + /** @return array */ 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[] */ @@ -42,155 +55,100 @@ 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 = '(?int|string|int\|string|string\|int|array-key)'; - $parameterPattern = '\s*\$?(?[\\p{L}0-9_]+)?'; - - preg_match_all( - "/{$kindPattern}(?{$typesPattern}){$parameterPattern}/ui", - $comment, - $arrayMatches, - ); - - preg_match_all( - "/{$kindPattern}(?{$fqsenPattern})<(?:{$keyPattern}\s*?,\s*?)?(?{$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( diff --git a/src/Support/Annotations/PhpDocumentorTypes/ArrayWithoutMixedDefault.php b/src/Support/Annotations/PhpDocumentorTypes/ArrayWithoutMixedDefault.php new file mode 100644 index 000000000..d8792f20d --- /dev/null +++ b/src/Support/Annotations/PhpDocumentorTypes/ArrayWithoutMixedDefault.php @@ -0,0 +1,26 @@ +originalValueType = $valueType; + } + + public function getOriginalValueType(): ?Type + { + return $this->originalValueType; + } +} diff --git a/src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php b/src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php new file mode 100644 index 000000000..3236ab39e --- /dev/null +++ b/src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php @@ -0,0 +1,45 @@ +originalValueType = $valueType; + } + + public function getOriginalValueType(): ?Type + { + return $this->originalValueType; + } + + /** Based on {@see Iterable_::__toString()}. */ + public function __toString(): string + { + if ($this->keyType) { + return 'iterable<' . $this->keyType . ',' . $this->valueType . '>'; + } + + if ($this->valueType instanceof Mixed_) { + return 'iterable'; + } + + return 'iterable<' . $this->valueType . '>'; + } +} diff --git a/src/Support/DataConfig.php b/src/Support/DataConfig.php index 946bdc877..f127bb83c 100644 --- a/src/Support/DataConfig.php +++ b/src/Support/DataConfig.php @@ -59,6 +59,7 @@ public function __construct( public function getDataClass(string $class): DataClass { + $class = explode('|', $class)[0]; return $this->dataClasses[$class] ??= DataContainer::get()->dataClassFactory()->build(new ReflectionClass($class)); } diff --git a/src/Support/Factories/DataPropertyFactory.php b/src/Support/Factories/DataPropertyFactory.php index 74cb58664..17f395232 100644 --- a/src/Support/Factories/DataPropertyFactory.php +++ b/src/Support/Factories/DataPropertyFactory.php @@ -32,7 +32,7 @@ public function build( mixed $defaultValue = null, ?NameMapper $classInputNameMapper = null, ?NameMapper $classOutputNameMapper = null, - ?DataIterableAnnotation $classDefinedDataIterableAnnotation = null, + ?array $classDefinedDataIterableAnnotations = null, ?AutoLazy $classAutoLazy = null, ): DataProperty { $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty($reflectionProperty); @@ -42,7 +42,7 @@ public function build( $reflectionClass, $reflectionProperty, $attributes, - $classDefinedDataIterableAnnotation + $classDefinedDataIterableAnnotations ); $mappers = NameMappersResolver::create()->execute($attributes); diff --git a/src/Support/Factories/DataTypeFactory.php b/src/Support/Factories/DataTypeFactory.php index 534b06fca..c43694f6f 100644 --- a/src/Support/Factories/DataTypeFactory.php +++ b/src/Support/Factories/DataTypeFactory.php @@ -17,7 +17,6 @@ use Spatie\LaravelData\Lazy; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Annotations\CollectionAnnotationReader; -use Spatie\LaravelData\Support\Annotations\DataIterableAnnotation; use Spatie\LaravelData\Support\Annotations\DataIterableAnnotationReader; use Spatie\LaravelData\Support\DataAttributesCollection; use Spatie\LaravelData\Support\DataPropertyType; @@ -41,14 +40,14 @@ public function buildProperty( ReflectionClass|string $class, ReflectionProperty|ReflectionParameter|string $typeable, ?DataAttributesCollection $attributes = null, - ?DataIterableAnnotation $classDefinedDataIterableAnnotation = null, + ?array $classDefinedDataIterableAnnotations = null, ): DataPropertyType { $properties = $this->infer( reflectionType: $reflectionType, class: $class, typeable: $typeable, attributes: $attributes, - classDefinedDataIterableAnnotation: $classDefinedDataIterableAnnotation, + classDefinedDataIterableAnnotations: $classDefinedDataIterableAnnotations, inferForProperty: true, ); @@ -77,7 +76,7 @@ public function build( class: $class, typeable: $typeable, attributes: null, - classDefinedDataIterableAnnotation: null, + classDefinedDataIterableAnnotations: null, inferForProperty: false, ); @@ -101,7 +100,7 @@ public function buildFromString( class: $class, typeable: $type, attributes: null, - classDefinedDataIterableAnnotation: null, + classDefinedDataIterableAnnotations: null, inferForProperty: false, ); @@ -132,7 +131,7 @@ protected function infer( ReflectionClass|string $class, ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, ?DataAttributesCollection $attributes, - ?DataIterableAnnotation $classDefinedDataIterableAnnotation, + ?array $classDefinedDataIterableAnnotations, bool $inferForProperty, ): array { if ($reflectionType === null) { @@ -145,7 +144,7 @@ protected function infer( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty, ); } @@ -156,7 +155,7 @@ protected function infer( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty, ); } @@ -225,7 +224,7 @@ protected function inferPropertiesForSingleType( ReflectionClass|string $class, ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, ?DataAttributesCollection $attributes, - ?DataIterableAnnotation $classDefinedDataIterableAnnotation, + ?array $classDefinedDataIterableAnnotations, bool $inferForProperty, ): array { return [ @@ -235,7 +234,7 @@ protected function inferPropertiesForSingleType( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty, ), 'isOptional' => false, @@ -245,7 +244,7 @@ protected function inferPropertiesForSingleType( /** * @return array{ - * type: NamedType, + * type: NamedType|UnionType, * isMixed: bool, * kind: DataTypeKind, * dataClass: ?string, @@ -261,7 +260,7 @@ protected function inferPropertiesForNamedType( ReflectionClass|string $class, ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, ?DataAttributesCollection $attributes, - ?DataIterableAnnotation $classDefinedDataIterableAnnotation, + ?array $classDefinedDataIterableAnnotations, bool $inferForProperty, ): array { if ($name === 'self' || $name === 'static') { @@ -331,21 +330,33 @@ protected function inferPropertiesForNamedType( if ( $iterableItemType === null - && $classDefinedDataIterableAnnotation + && ! empty($classDefinedDataIterableAnnotations) ) { - $isData = $classDefinedDataIterableAnnotation->isData; - $iterableItemType = $classDefinedDataIterableAnnotation->type; - $iterableKeyType = $classDefinedDataIterableAnnotation->keyType; + if (count($classDefinedDataIterableAnnotations) == 1) { + $isData = $classDefinedDataIterableAnnotations[0]->isData; + $iterableItemType = $classDefinedDataIterableAnnotations[0]->type; + $iterableKeyType = $classDefinedDataIterableAnnotations[0]->keyType; + } else { + $isData = array_column($classDefinedDataIterableAnnotations, 'isData'); + $iterableItemType = array_column($classDefinedDataIterableAnnotations, 'type'); + $iterableKeyType = array_column($classDefinedDataIterableAnnotations, 'keyType'); + } } if ( $iterableItemType === null && $typeable instanceof ReflectionProperty - && $annotation = $this->iterableAnnotationReader->getForProperty($typeable) + && ! empty($annotations = $this->iterableAnnotationReader->getForProperty($typeable)) ) { - $isData = $annotation->isData; - $iterableItemType = $annotation->type; - $iterableKeyType = $annotation->keyType; + if (count($annotations) == 1) { + $isData = $annotations[0]->isData; + $iterableItemType = $annotations[0]->type; + $iterableKeyType = $annotations[0]->keyType; + } else { + $isData = array_column($annotations, 'isData'); + $iterableItemType = array_column($annotations, 'type'); + $iterableKeyType = array_column($annotations, 'keyType'); + } } if ( @@ -359,6 +370,34 @@ protected function inferPropertiesForNamedType( $iterableKeyType = $annotation->keyType; } + if (is_array($isData)) { + $types = []; + foreach ($isData as $i => $iD) { + $types[] = new NamedType( + name: $name, + builtIn: $builtIn, + acceptedTypes: $acceptedTypes, + kind: $iD ? $kind->getDataRelatedEquivalent() : $kind, + dataClass: $iD ? $iterableItemType[$i] : null, + dataCollectableClass: $iD ? $name : null, + iterableClass: $name, + iterableItemType: $iterableItemType[$i], + iterableKeyType: $iterableKeyType[$i], + ); + } + + return [ + 'type' => new UnionType($types), + 'isMixed' => $isMixed, + 'kind' => $kind, + 'dataClass' => join('|', array_unique($iterableItemType)), + 'dataCollectableClass' => null, + 'iterableClass' => $name, + 'iterableItemType' => join('|', array_unique($iterableItemType)), + 'iterableKeyType' => join('|', array_unique($iterableKeyType)), + ]; + } + $kind = $isData ? $kind->getDataRelatedEquivalent() : $kind; @@ -408,7 +447,7 @@ protected function inferPropertiesForCombinationType( ReflectionClass|string $class, ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, ?DataAttributesCollection $attributes, - ?DataIterableAnnotation $classDefinedDataIterableAnnotation, + ?array $classDefinedDataIterableAnnotations, bool $inferForProperty, ): array { $isMixed = false; @@ -432,7 +471,7 @@ protected function inferPropertiesForCombinationType( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty ); @@ -477,7 +516,7 @@ protected function inferPropertiesForCombinationType( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty ); diff --git a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php index 27208836a..990344115 100644 --- a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php +++ b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php @@ -4,6 +4,7 @@ use phpDocumentor\Reflection\Fqsen; use phpDocumentor\Reflection\Type; +use phpDocumentor\Reflection\TypeResolver; use phpDocumentor\Reflection\Types\Array_; use phpDocumentor\Reflection\Types\Boolean; use phpDocumentor\Reflection\Types\Compound; @@ -138,8 +139,19 @@ protected function dataCollectionType(string $class, ?string $keyType): Type default => new Compound([new String_(), new Integer()]), }; + $classes = explode('|', $class); + + $typeResolver = new TypeResolver(); + + if (count($classes) == 1) { + return new Array_( + $typeResolver->resolve($class), + $keyType + ); + } + return new Array_( - new Object_(new Fqsen("\\{$class}")), + new Compound(array_map(fn ($class) => $typeResolver->resolve($class), $classes)), $keyType ); } diff --git a/tests/Fakes/CollectionDataAnnotationsData.php b/tests/Fakes/CollectionDataAnnotationsData.php index 0fbc8f46a..530455cd4 100644 --- a/tests/Fakes/CollectionDataAnnotationsData.php +++ b/tests/Fakes/CollectionDataAnnotationsData.php @@ -44,7 +44,7 @@ class CollectionDataAnnotationsData public DataCollection $propertyH; /** @var SimpleData */ - public DataCollection $propertyI; // FAIL + public DataCollection $propertyI; public DataCollection $propertyJ; @@ -80,7 +80,7 @@ class CollectionDataAnnotationsData public ?array $propertyW; /** - * @param \Spatie\LaravelData\Tests\Fakes\SimpleData[]|null $paramA + * @param \Spatie\LaravelData\Tests\Fakes\SimpleData[]|null $paramA with some text * @param null|\Spatie\LaravelData\Tests\Fakes\SimpleData[] $paramB * @param ?\Spatie\LaravelData\Tests\Fakes\SimpleData[] $paramC * @param ?\Spatie\LaravelData\Tests\Fakes\SimpleData[] $paramD diff --git a/tests/Fakes/CollectionNonDataAnnotationsData.php b/tests/Fakes/CollectionNonDataAnnotationsData.php index 1e858f7ff..997d0df3b 100644 --- a/tests/Fakes/CollectionNonDataAnnotationsData.php +++ b/tests/Fakes/CollectionNonDataAnnotationsData.php @@ -37,7 +37,7 @@ class CollectionNonDataAnnotationsData public array $propertyG; /** @var DummyBackedEnum */ - public array $propertyH; // FAIL + public array $propertyH; public array $propertyI; diff --git a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php index a1d22faa6..7a0a8e7e3 100644 --- a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php +++ b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php @@ -1,5 +1,6 @@ getForProperty(new ReflectionProperty(CollectionDataAnnotationsData::class, $property)); expect($annotations)->toEqual($expected); @@ -20,125 +21,180 @@ function (string $property, ?DataIterableAnnotation $expected) { )->with(function () { yield 'propertyA' => [ 'propertyA', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyB' => [ 'propertyB', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [ + new DataIterableAnnotation(SimpleData::class, isData: true), + new DataIterableAnnotation('null', isData: false), + ], // expected ]; yield 'propertyC' => [ 'propertyC', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [ + new DataIterableAnnotation(SimpleData::class, isData: true), + new DataIterableAnnotation('null', isData: false), + ], // expected ]; yield 'propertyD' => [ 'propertyD', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyE' => [ 'propertyE', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyE' => [ 'propertyE', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyF' => [ 'propertyF', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyG' => [ 'propertyG', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyH' => [ // Attribute 'propertyH', // property - null, // expected + [], // expected ]; yield 'propertyI' => [ // Invalid definition 'propertyI', // property - null, // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyJ' => [ // No definition 'propertyJ', // property - null, // expected + [], // expected ]; yield 'propertyK' => [ 'propertyK', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyL' => [ 'propertyL', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyM' => [ 'propertyM', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [new DataIterableAnnotation(SimpleData::class, isData: true)], // expected ]; yield 'propertyU' => [ 'propertyU', // property - new DataIterableAnnotation(SimpleData::class, isData: true), // expected + [ + new DataIterableAnnotation(SimpleData::class, isData: true), + new DataIterableAnnotation('null', isData: false), + ], // expected ]; yield 'propertyV' => [ 'propertyV', // property - new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true), // expected + [ + new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true), + new DataIterableAnnotation('null', isData: false), + ], // expected ]; }); it('can get the data class for a data collection by class annotation', function () { $annotations = app(DataIterableAnnotationReader::class)->getForClass(new ReflectionClass(CollectionDataAnnotationsData::class)); - expect($annotations)->toEqualCanonicalizing([ - 'propertyN' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyN'), - 'propertyO' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyO'), - 'propertyP' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyP'), - 'propertyQ' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyQ'), - 'propertyR' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyR'), - 'propertyS' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyS'), - 'propertyT' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyT'), - 'propertyW' => new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'propertyW'), + expect($annotations)->toEqual([ + 'propertyN' => [new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyN')], + 'propertyO' => [new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyO')], + 'propertyP' => [new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyP')], + 'propertyQ' => [new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyQ')], + 'propertyR' => [new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyR')], + 'propertyS' => [new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyS')], + 'propertyT' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'propertyT'), + new DataIterableAnnotation('null', isData: false, property: 'propertyT'), + ], + 'propertyW' => [ + new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'propertyW'), + new DataIterableAnnotation('null', isData: false, property: 'propertyW'), + ], ]); }); it('can get data class for a data collection by method annotation', function () { $annotations = app(DataIterableAnnotationReader::class)->getForMethod(new ReflectionMethod(CollectionDataAnnotationsData::class, 'method')); - expect($annotations)->toEqualCanonicalizing([ - 'paramA' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramA'), - 'paramB' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramB'), - 'paramC' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramC'), - 'paramD' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramD'), - 'paramE' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramE'), - 'paramF' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramF'), - 'paramG' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramG'), - 'paramH' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramH'), - 'paramJ' => new DataIterableAnnotation(SimpleData::class, isData: true, keyType: 'int', property: 'paramJ'), - 'paramI' => new DataIterableAnnotation(SimpleData::class, isData: true, keyType: 'int', property: 'paramI'), - 'paramK' => new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramK'), - 'paramL' => new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'paramL'), - 'paramM' => new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'paramM'), - 'paramN' => new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'paramN'), - 'paramO' => new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'paramO'), + expect($annotations)->toEqual([ // Canonicalizing([ + 'paramA' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramA'), + new DataIterableAnnotation('null', isData: false, property: 'paramA'), + ], + 'paramB' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramB'), + new DataIterableAnnotation('null', isData: false, property: 'paramB'), + ], + 'paramC' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramC'), + ], + 'paramD' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramD'), + ], + 'paramE' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramE'), + ], + 'paramF' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramF'), + ], + 'paramG' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramG'), + ], + 'paramH' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramH'), + ], + 'paramI' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, keyType: 'int', property: 'paramI'), + ], + 'paramJ' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, keyType: 'int', property: 'paramJ'), + ], + 'paramK' => [ + new DataIterableAnnotation(SimpleData::class, isData: true, property: 'paramK'), + new DataIterableAnnotation('null', isData: false, property: 'paramK'), + ], + 'paramL' => [ + new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'paramL'), + new DataIterableAnnotation('null', isData: false, property: 'paramL'), + ], + 'paramM' => [ + new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'paramM'), + new DataIterableAnnotation('null', isData: false, property: 'paramM'), + ], + 'paramN' => [ + new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'paramN'), + new DataIterableAnnotation('null', isData: false, property: 'paramN'), + ], + 'paramO' => [ + new DataIterableAnnotation(SimpleDataWithUnicodeCharséÄöü::class, isData: true, property: 'paramO'), + new DataIterableAnnotation('null', isData: false, property: 'paramO'), + ], ]); }); it( 'can get the iterable class for a collection by annotation', - function (string $property, ?DataIterableAnnotation $expected) { + function (string $property, array $expected) { $annotations = app(DataIterableAnnotationReader::class)->getForProperty(new ReflectionProperty(CollectionNonDataAnnotationsData::class, $property)); expect($annotations)->toEqual($expected); @@ -146,101 +202,138 @@ function (string $property, ?DataIterableAnnotation $expected) { )->with(function () { yield 'propertyA' => [ 'propertyA', // property - new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected + [new DataIterableAnnotation(DummyBackedEnum::class, isData: false)], // expected ]; yield 'propertyB' => [ 'propertyB', // property - new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected + [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + new DataIterableAnnotation('null', isData: false), + ], // expected ]; yield 'propertyC' => [ 'propertyC', // property - new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected + [ + new DataIterableAnnotation('null', isData: false), + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + ], // expected ]; yield 'propertyD' => [ 'propertyD', // property - new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected + [new DataIterableAnnotation(DummyBackedEnum::class, isData: false)], // expected ]; yield 'propertyE' => [ 'propertyE', // property - new DataIterableAnnotation('string', isData: false), // expected + [new DataIterableAnnotation('string', isData: false)], // expected ]; yield 'propertyF' => [ 'propertyF', // property - new DataIterableAnnotation('string', isData: false), // expected + [new DataIterableAnnotation('string', isData: false)], // expected ]; yield 'propertyG' => [ 'propertyG', // property - new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected + [new DataIterableAnnotation(DummyBackedEnum::class, isData: false)], // expected ]; yield 'propertyH' => [ // Invalid 'propertyH', // property - null, // expected + [new DataIterableAnnotation(DummyBackedEnum::class, isData: false)], // expected ]; yield 'propertyI' => [ // No definition 'propertyI', // property - null, // expected + [], // expected ]; yield 'propertyJ' => [ 'propertyJ', // property - new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected + [new DataIterableAnnotation(DummyBackedEnum::class, isData: false)], // expected ]; yield 'propertyK' => [ 'propertyK', // property - new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected + [new DataIterableAnnotation(DummyBackedEnum::class, isData: false)], // expected ]; yield 'propertyL' => [ 'propertyL', // property - new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected + [new DataIterableAnnotation(DummyBackedEnum::class, isData: false)], // expected ]; yield 'propertyP' => [ 'propertyP', // property - new DataIterableAnnotation(Error::class, isData: true), // expected + [new DataIterableAnnotation(Error::class, isData: true)], // expected ]; yield 'propertyR' => [ 'propertyR', // property - new DataIterableAnnotation(Error::class, isData: true), // expected + [ + new DataIterableAnnotation(Error::class, isData: true), + new DataIterableAnnotation('null', isData: false), + ], // expected ]; }); it('can get the iterable class for a collection by class annotation', function () { $annotations = app(DataIterableAnnotationReader::class)->getForClass(new ReflectionClass(CollectionNonDataAnnotationsData::class)); - expect($annotations)->toEqualCanonicalizing([ - 'propertyM' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'propertyM'), - 'propertyN' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'propertyN'), - 'propertyO' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'propertyO'), - 'propertyQ' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'propertyQ'), + expect($annotations)->toEqual([ + 'propertyM' => [new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'propertyM')], + 'propertyN' => [new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'propertyN')], + 'propertyO' => [new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'propertyO')], + 'propertyQ' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'propertyQ'), + new DataIterableAnnotation('null', isData: false, property: 'propertyQ'), + ], ]); }); it('can get iterable class for a data by method annotation', function () { $annotations = app(DataIterableAnnotationReader::class)->getForMethod(new ReflectionMethod(CollectionNonDataAnnotationsData::class, 'method')); - expect($annotations)->toEqualCanonicalizing([ - 'paramA' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramA'), - 'paramB' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramB'), - 'paramC' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramC'), - 'paramD' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramD'), - 'paramE' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramE'), - 'paramF' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramF'), - 'paramG' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramG'), - 'paramH' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramH'), - 'paramJ' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, keyType: 'int', property: 'paramJ'), - 'paramI' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, keyType: 'int', property: 'paramI'), - 'paramK' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramK'), + expect($annotations)->toEqual([ + 'paramA' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramA'), + new DataIterableAnnotation('null', isData: false, property: 'paramA'), + ], + 'paramB' => [ + new DataIterableAnnotation('null', isData: false, property: 'paramB'), + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramB'), + ], + 'paramC' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramC'), + ], + 'paramD' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramD'), + ], + 'paramE' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramE'), + ], + 'paramF' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramF'), + ], + 'paramG' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramG'), + ], + 'paramH' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramH'), + ], + 'paramJ' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, keyType: 'int', property: 'paramJ'), + ], + 'paramI' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, keyType: 'int', property: 'paramI'), + ], + 'paramK' => [ + new DataIterableAnnotation(DummyBackedEnum::class, isData: false, property: 'paramK'), + new DataIterableAnnotation('null', isData: false, property: 'paramK'), + ], ]); }); @@ -248,13 +341,27 @@ function (string $property, ?DataIterableAnnotation $expected) { $dataClass = new class () extends Data { /** @var array */ public array $property; + /** @var Collection */ + public Collection $collection; }; $annotations = app(DataIterableAnnotationReader::class)->getForProperty( new ReflectionProperty($dataClass::class, 'property') ); - expect($annotations)->toEqual(new DataIterableAnnotation(SimpleData::class, isData: true)); + expect($annotations)->toEqual([ + new DataIterableAnnotation(SimpleData::class, isData: true), + new DataIterableAnnotation('string', isData: false), + ]); + + $annotations = app(DataIterableAnnotationReader::class)->getForProperty( + new ReflectionProperty($dataClass::class, 'collection') + ); + + expect($annotations)->toEqual([ + new DataIterableAnnotation(SimpleData::class, isData: true, keyType: 'int'), + new DataIterableAnnotation('string', isData: false, keyType: 'int'), + ]); }); it('will recognize default PHP types', function (string $type) { @@ -291,7 +398,7 @@ function (string $property, ?DataIterableAnnotation $expected) { app(DataIterableAnnotationReader::class)->getForProperty( new ReflectionProperty($dataClass::class, $type) ) - )->toEqual(new DataIterableAnnotation($type, isData: false)); + )->toEqual([new DataIterableAnnotation($type, isData: false)]); })->with([ 'string' => ['string'], 'int' => ['int'], @@ -332,7 +439,7 @@ function (string $property, ?DataIterableAnnotation $expected) { app(DataIterableAnnotationReader::class)->getForProperty( new ReflectionProperty($dataClass::class, $property) ) - )->toEqual(new DataIterableAnnotation('float', isData: false, keyType: $keyType)); + )->toEqual([new DataIterableAnnotation('float', isData: false, keyType: $keyType)]); })->with([ ['propertyA', 'string'], // string key ['propertyB', 'int'], // int key diff --git a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php index e86fd90a1..6a5bab233 100644 --- a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php +++ b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php @@ -1,7 +1,10 @@ 'Lazy'), Lazy::closure(fn () => 'Lazy'), SimpleData::from('Simple data'), SimpleData::collect([], DataCollection::class), SimpleData::collect([], DataCollection::class), SimpleData::collect([], DataCollection::class)) extends Data { + $foo = new class () extends Model { + protected $attributes = [ + 'id' => 123, + ]; + }; + + $data = new class (null, Optional::create(), 42, true, 'Hello world', 3.14, ['the', 'meaning', 'of', 'life'], Lazy::create(fn () => 'Lazy'), Lazy::closure(fn () => 'Lazy'), SimpleData::from('Simple data'), SimpleData::collect([], DataCollection::class), SimpleData::collect([], DataCollection::class), SimpleData::collect([], DataCollection::class), SimpleData::collect([], DataCollection::class), collect([6, new SimpleData('simp')]), new EloquentCollection([$foo]), [6, new SimpleData('simpler')]) extends Data { public function __construct( public null|int $nullable, public Optional|int $undefineable, @@ -48,8 +57,16 @@ public function __construct( public DataCollection $dataCollection, /** @var DataCollection<\Spatie\LaravelData\Tests\Fakes\SimpleData> */ public DataCollection $dataCollectionAlternative, + /** @var DataCollection<\Spatie\LaravelData\Tests\Fakes\SimpleData>|array */ + public DataCollection $dataCollectionUnion, #[DataCollectionOf(SimpleData::class)] public DataCollection $dataCollectionWithAttribute, + /** @var Collection */ + public Collection $collectionWithUnion, + /** @var Collection|array */ + public EloquentCollection|array $collectionOrArrayWithUnion, + /** @var array */ + public array $arrayWithUnion, ) { } }; diff --git a/tests/__snapshots__/DataTypeScriptTransformerTest__it_can_convert_a_data_object_to_Typescript__1.txt b/tests/__snapshots__/DataTypeScriptTransformerTest__it_can_convert_a_data_object_to_Typescript__1.txt index 8e3d70032..259971e11 100644 --- a/tests/__snapshots__/DataTypeScriptTransformerTest__it_can_convert_a_data_object_to_Typescript__1.txt +++ b/tests/__snapshots__/DataTypeScriptTransformerTest__it_can_convert_a_data_object_to_Typescript__1.txt @@ -11,5 +11,9 @@ closureLazy: string; simpleData: {%Spatie\LaravelData\Tests\Fakes\SimpleData%}; dataCollection: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; dataCollectionAlternative: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; +dataCollectionUnion: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%} | number>; dataCollectionWithAttribute: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; +collectionWithUnion: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%} | number>; +collectionOrArrayWithUnion: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}> | Array; +arrayWithUnion: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%} | number>; } \ No newline at end of file