From 4f831fc6f68798dcb9896f343551e8dec35f3a81 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Fri, 17 Oct 2025 15:31:19 +0200 Subject: [PATCH 01/13] added functionality to correctly read array union values to be transformed into typescript --- .../DataIterableAnnotationReader.php | 120 ++++++++++-------- src/Support/Factories/DataTypeFactory.php | 41 +++++- .../DataTypeScriptTransformer.php | 1 + .../DataTypeScriptTransformerTest.php | 7 +- ...convert_a_data_object_to_Typescript__1.txt | 2 + 5 files changed, 111 insertions(+), 60 deletions(-) diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index 1e406524..6aafcfad 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -28,7 +28,15 @@ public function getForClass(ReflectionClass $class): array public function getForProperty(ReflectionProperty $property): ?DataIterableAnnotation { - return Arr::first($this->get($property)); + /** Also {@see resolveDataClass()}. */ + $annotations = $this->get($property); + return Arr::first($annotations, fn (DataIterableAnnotation $a) => $a->isData) ?? Arr::first($annotations); + } + + /** @return DataIterableAnnotation[] */ + public function getAllForProperty(ReflectionProperty $property): array + { + return $this->get($property); } /** @return array */ @@ -116,14 +124,15 @@ protected function resolveCollectionAnnotations( $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 - ); + $types = $this->resolveTypes($reflection, $dataClass); + foreach ($types as $resolvedTuple) { + $annotations[] = new DataIterableAnnotation( + type: $resolvedTuple['type'], + isData: $resolvedTuple['isData'], + keyType: empty($key) ? 'array-key' : $key, + property: empty($parameter) ? null : $parameter + ); + } } return $annotations; @@ -136,61 +145,64 @@ 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); - - if ($resolvedTuple['isData']) { - return $resolvedTuple; - } + $types = $this->resolveTypes($reflection, $class); + /** Also {@see getForProperty()}. */ + return Arr::first($types, fn (array $type) => $type['isData']) ?? Arr::first($types) ?? [ + 'type' => $class, + 'isData' => false, + ]; + } - $possibleNonDataType = $resolvedTuple['type']; + /** + * @return array{type: string, isData: bool}[] + */ + protected function resolveTypes( + ReflectionProperty|ReflectionClass|ReflectionMethod $reflection, + string $class + ): array { + $types = []; + foreach (explode('|', $class) as $explodedClass) { + if (in_array($explodedClass, ['int', 'string', 'bool', 'float', 'array', 'object', 'callable', 'iterable', 'mixed'])) { + $types[] = [ + 'type' => $explodedClass, + 'isData' => false, + ]; + continue; } - 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, - ]; - } + $explodedClass = ltrim($explodedClass, '\\'); + if (is_subclass_of($explodedClass, BaseData::class)) { + $types[] = [ + 'type' => $explodedClass, + 'isData' => true, + ]; + continue; + } - $fcqn = $this->resolveFcqn($reflection, $class); + $fcqn = $this->resolveFcqn($reflection, $explodedClass); + if (is_subclass_of($fcqn, BaseData::class)) { + $types[] = [ + 'type' => $fcqn, + 'isData' => true, + ]; + continue; + } - if (is_subclass_of($fcqn, BaseData::class)) { - return [ - 'type' => $fcqn, - 'isData' => true, - ]; - } + if (class_exists($fcqn)) { + $types[] = [ + 'type' => $fcqn, + 'isData' => false, + ]; + continue; + } - if (class_exists($fcqn)) { - return [ - 'type' => $fcqn, + $types[] = [ + 'type' => $explodedClass, 'isData' => false, ]; } - return [ - 'type' => $class, - 'isData' => false, - ]; + return $types; } protected function resolveFcqn( diff --git a/src/Support/Factories/DataTypeFactory.php b/src/Support/Factories/DataTypeFactory.php index 534b06fc..e126882d 100644 --- a/src/Support/Factories/DataTypeFactory.php +++ b/src/Support/Factories/DataTypeFactory.php @@ -3,6 +3,7 @@ namespace Spatie\LaravelData\Support\Factories; use Exception; +use phpDocumentor\Reflection\Types\Compound; use ReflectionClass; use ReflectionIntersectionType; use ReflectionMethod; @@ -245,7 +246,7 @@ protected function inferPropertiesForSingleType( /** * @return array{ - * type: NamedType, + * type: NamedType|UnionType, * isMixed: bool, * kind: DataTypeKind, * dataClass: ?string, @@ -341,11 +342,41 @@ protected function inferPropertiesForNamedType( if ( $iterableItemType === null && $typeable instanceof ReflectionProperty - && $annotation = $this->iterableAnnotationReader->getForProperty($typeable) + && $annotations = $this->iterableAnnotationReader->getAllForProperty($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; + } elseif (count($annotations) > 1) { + $iterableItemType = join('|', array_unique(array_column($annotations, 'type'))); + $iterableKeyType = join('|', array_unique(array_column($annotations, 'keyType'))); + return [ + 'type' => new UnionType( + array_map( + fn (DataIterableAnnotation $annotation) => new NamedType( + name: $name, + builtIn: $builtIn, + acceptedTypes: $acceptedTypes, + kind: $annotation->isData ? $kind->getDataRelatedEquivalent() : $kind, + dataClass: $annotation->isData ? $annotation->type : null, + dataCollectableClass: $annotation->isData ? $name : null, + iterableClass: $name, + iterableItemType: $annotation->type, + iterableKeyType: $annotation->keyType, + ), + $annotations, + ) + ), + 'isMixed' => $isMixed, + 'kind' => $kind, + 'dataClass' => null, + 'dataCollectableClass' => null, + 'iterableClass' => $name, + 'iterableItemType' => $iterableItemType, + 'iterableKeyType' => $iterableKeyType, + ]; + } } if ( diff --git a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php index 27208836..44ee668f 100644 --- a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php +++ b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php @@ -6,6 +6,7 @@ use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\Array_; use phpDocumentor\Reflection\Types\Boolean; +use phpDocumentor\Reflection\Types\Collection; use phpDocumentor\Reflection\Types\Compound; use phpDocumentor\Reflection\Types\Integer; use phpDocumentor\Reflection\Types\Nullable; diff --git a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php index e86fd90a..13c95e08 100644 --- a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php +++ b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php @@ -2,6 +2,7 @@ use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\CursorPaginatedDataCollection; @@ -31,7 +32,7 @@ function assertMatchesSnapshot($actual, ?Driver $driver = null): void it('can convert a data object to Typescript', function () { $config = TypeScriptTransformerConfig::create(); - $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)) extends Data { + $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), collect([6, new SimpleData('simp')]), [6, new SimpleData('simpler')]) extends Data { public function __construct( public null|int $nullable, public Optional|int $undefineable, @@ -50,6 +51,10 @@ public function __construct( public DataCollection $dataCollectionAlternative, #[DataCollectionOf(SimpleData::class)] public DataCollection $dataCollectionWithAttribute, + /** @var Collection */ + public Collection $collectionWithUnion, + /** @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 8e3d7003..7068232a 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 @@ -12,4 +12,6 @@ simpleData: {%Spatie\LaravelData\Tests\Fakes\SimpleData%}; dataCollection: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; dataCollectionAlternative: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; dataCollectionWithAttribute: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; +collectionWithUnion: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%} | number>; +arrayWithUnion: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%} | number>; } \ No newline at end of file From 2bed75dfc4ab478a985a51aa827c1f575d365268 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Fri, 17 Oct 2025 15:37:37 +0200 Subject: [PATCH 02/13] cleaned up bad import --- src/Support/Factories/DataTypeFactory.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Support/Factories/DataTypeFactory.php b/src/Support/Factories/DataTypeFactory.php index e126882d..623b27fc 100644 --- a/src/Support/Factories/DataTypeFactory.php +++ b/src/Support/Factories/DataTypeFactory.php @@ -3,7 +3,6 @@ namespace Spatie\LaravelData\Support\Factories; use Exception; -use phpDocumentor\Reflection\Types\Compound; use ReflectionClass; use ReflectionIntersectionType; use ReflectionMethod; From 42a9efc82b15ddcbc02e5a438653e0019b7f4fc2 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Fri, 17 Oct 2025 15:41:11 +0200 Subject: [PATCH 03/13] removed bad import --- src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php index 44ee668f..27208836 100644 --- a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php +++ b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php @@ -6,7 +6,6 @@ use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\Array_; use phpDocumentor\Reflection\Types\Boolean; -use phpDocumentor\Reflection\Types\Collection; use phpDocumentor\Reflection\Types\Compound; use phpDocumentor\Reflection\Types\Integer; use phpDocumentor\Reflection\Types\Nullable; From ab9c6ead257be355a2e3d83cd4a5b3d6002f44cf Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Thu, 23 Oct 2025 16:27:58 +0200 Subject: [PATCH 04/13] WIP:working on union issues --- .../DataIterableAnnotationReader.php | 206 ++++++------------ src/Support/Factories/DataTypeFactory.php | 17 +- tests/Fakes/CollectionDataAnnotationsData.php | 2 +- .../DataIterableAnnotationReaderTest.php | 13 +- .../DataTypeScriptTransformerTest.php | 12 +- 5 files changed, 102 insertions(+), 148 deletions(-) diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index 6aafcfad..1d2bb8ef 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -4,11 +4,19 @@ 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\Array_; +use phpDocumentor\Reflection\Types\Compound; +use phpDocumentor\Reflection\Types\ContextFactory; +use phpDocumentor\Reflection\Types\Nullable; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Resolvers\ContextResolver; +use Spatie\LaravelData\Support\Types\Storage\AcceptedTypesStorage; /** * @note To myself, always use the fully qualified class names in pest tests when using anonymous classes @@ -23,13 +31,13 @@ public function __construct( /** @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))->reverse()->keyBy(fn (DataIterableAnnotation $annotation) => $annotation->property)->all(); } public function getForProperty(ReflectionProperty $property): ?DataIterableAnnotation { - /** Also {@see resolveDataClass()}. */ $annotations = $this->get($property); + return Arr::first($annotations, fn (DataIterableAnnotation $a) => $a->isData) ?? Arr::first($annotations); } @@ -50,87 +58,70 @@ 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('/(?:@property(?:-read)?|@var|@param)\s*(.+)\s*(\$[a-zA-Z_][a-zA-Z0-9_]*)?.*$/uim', $comment, $matches); + if (! $hasType) { + return []; } - return $annotations; - } - - protected function resolveCollectionAnnotations( - ReflectionProperty|ReflectionClass|ReflectionMethod $reflection, - array $collectionMatches - ): array { $annotations = []; + foreach ($matches[1] ?? [] as $index => $type) { + $property = empty($matches[2][$index]) ? null : ltrim($matches[2][$index], '$'); + $type = (new TypeResolver())->resolve($type); // , (new ContextFactory())->createFromReflector($reflection)); + + /** @return Type[] */ + $commentTypeToDataStrings = function (Type $type) use (&$commentTypeToDataStrings): array { + if ($type instanceof Compound) { + return array_merge(...array_map(fn (Type $t) => $commentTypeToDataStrings($t), iterator_to_array($type))); + } elseif ($type instanceof AbstractList) { + return $commentTypeToDataStrings($type->getValueType()); + } elseif ($type instanceof Nullable) { + return $commentTypeToDataStrings($type->getActualType()); + } else { + return [(string) $type]; + } + }; + $typeStrings = $commentTypeToDataStrings($type); + foreach ($typeStrings as $typeString) { + if (in_array($typeString, ['int', 'string', 'bool', 'float', 'array', 'object', 'callable', 'iterable', 'mixed'])) { + $annotations[] = new DataIterableAnnotation( + type: $typeString, + isData: false, + property: $property, + ); + + continue; + } + + $typeString = ltrim($typeString, '\\'); + if (is_subclass_of($typeString, BaseData::class)) { + $annotations[] = new DataIterableAnnotation( + type: $typeString, + isData: true, + property: $property, + ); + + continue; + } + + $fcqn = $this->resolveFcqn($reflection, $typeString); + if (class_exists($fcqn)) { + $annotations[] = new DataIterableAnnotation( + type: $fcqn, + isData: is_subclass_of($fcqn, BaseData::class), + property: $property, + ); + + continue; + } - foreach ($collectionMatches['dataClass'] as $index => $dataClass) { - $parameter = $collectionMatches['parameter'][$index]; - $key = $collectionMatches['key'][$index]; - - $types = $this->resolveTypes($reflection, $dataClass); - foreach ($types as $resolvedTuple) { $annotations[] = new DataIterableAnnotation( - type: $resolvedTuple['type'], - isData: $resolvedTuple['isData'], - keyType: empty($key) ? 'array-key' : $key, - property: empty($parameter) ? null : $parameter + type: $typeString, + isData: false, + property: $property, ); } } @@ -138,73 +129,6 @@ protected function resolveCollectionAnnotations( return $annotations; } - /** - * @return array{type: string, isData: bool} - */ - protected function resolveDataClass( - ReflectionProperty|ReflectionClass|ReflectionMethod $reflection, - string $class - ): array { - $types = $this->resolveTypes($reflection, $class); - /** Also {@see getForProperty()}. */ - return Arr::first($types, fn (array $type) => $type['isData']) ?? Arr::first($types) ?? [ - 'type' => $class, - 'isData' => false, - ]; - } - - /** - * @return array{type: string, isData: bool}[] - */ - protected function resolveTypes( - ReflectionProperty|ReflectionClass|ReflectionMethod $reflection, - string $class - ): array { - $types = []; - foreach (explode('|', $class) as $explodedClass) { - if (in_array($explodedClass, ['int', 'string', 'bool', 'float', 'array', 'object', 'callable', 'iterable', 'mixed'])) { - $types[] = [ - 'type' => $explodedClass, - 'isData' => false, - ]; - continue; - } - - $explodedClass = ltrim($explodedClass, '\\'); - if (is_subclass_of($explodedClass, BaseData::class)) { - $types[] = [ - 'type' => $explodedClass, - 'isData' => true, - ]; - continue; - } - - $fcqn = $this->resolveFcqn($reflection, $explodedClass); - if (is_subclass_of($fcqn, BaseData::class)) { - $types[] = [ - 'type' => $fcqn, - 'isData' => true, - ]; - continue; - } - - if (class_exists($fcqn)) { - $types[] = [ - 'type' => $fcqn, - 'isData' => false, - ]; - continue; - } - - $types[] = [ - 'type' => $explodedClass, - 'isData' => false, - ]; - } - - return $types; - } - protected function resolveFcqn( ReflectionProperty|ReflectionClass|ReflectionMethod $reflection, string $class diff --git a/src/Support/Factories/DataTypeFactory.php b/src/Support/Factories/DataTypeFactory.php index 623b27fc..67e59219 100644 --- a/src/Support/Factories/DataTypeFactory.php +++ b/src/Support/Factories/DataTypeFactory.php @@ -348,6 +348,17 @@ protected function inferPropertiesForNamedType( $iterableItemType = $annotations[0]->type; $iterableKeyType = $annotations[0]->keyType; } elseif (count($annotations) > 1) { + // $isData = false; + // $dataClass = null; + // $dataCollectableClass = null; + // foreach ($annotations as $annotation) { + // if ($annotation->isData) { + // $isData = true; + // $dataClass = $annotation->type; + // $dataCollectableClass = $name; + // break; + // } + // } $iterableItemType = join('|', array_unique(array_column($annotations, 'type'))); $iterableKeyType = join('|', array_unique(array_column($annotations, 'keyType'))); return [ @@ -368,9 +379,9 @@ protected function inferPropertiesForNamedType( ) ), 'isMixed' => $isMixed, - 'kind' => $kind, - 'dataClass' => null, - 'dataCollectableClass' => null, + 'kind' => $kind, // $isData ? $kind->getDataRelatedEquivalent() : $kind, + 'dataClass' => null, // $dataClass, + 'dataCollectableClass' => null, // $dataCollectableClass, 'iterableClass' => $name, 'iterableItemType' => $iterableItemType, 'iterableKeyType' => $iterableKeyType, diff --git a/tests/Fakes/CollectionDataAnnotationsData.php b/tests/Fakes/CollectionDataAnnotationsData.php index 0fbc8f46..255dad04 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; diff --git a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php index a1d22faa..1d75de16 100644 --- a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php +++ b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php @@ -1,5 +1,6 @@ [ // Invalid definition 'propertyI', // property - null, // expected + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyJ' => [ // No definition @@ -248,6 +249,8 @@ 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( @@ -255,6 +258,12 @@ function (string $property, ?DataIterableAnnotation $expected) { ); expect($annotations)->toEqual(new DataIterableAnnotation(SimpleData::class, isData: true)); + + $annotations = app(DataIterableAnnotationReader::class)->getForProperty( + new ReflectionProperty($dataClass::class, 'collection') + ); + + expect($annotations)->toEqual(new DataIterableAnnotation(SimpleData::class, isData: true, keyType: 'array-key')); }); it('will recognize default PHP types', function (string $type) { @@ -291,7 +300,7 @@ function (string $property, ?DataIterableAnnotation $expected) { app(DataIterableAnnotationReader::class)->getForProperty( new ReflectionProperty($dataClass::class, $type) ) - )->toEqual(new DataIterableAnnotation($type, isData: false)); + )->toEqual(new DataIterableAnnotation(str_replace(['array', 'iterable'], 'mixed', $type), isData: false)); })->with([ 'string' => ['string'], 'int' => ['int'], diff --git a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php index 13c95e08..024802a2 100644 --- a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php +++ b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php @@ -1,5 +1,7 @@ 'Lazy'), Lazy::closure(fn () => 'Lazy'), SimpleData::from('Simple data'), SimpleData::collect([], DataCollection::class), SimpleData::collect([], DataCollection::class), SimpleData::collect([], DataCollection::class), collect([6, new SimpleData('simp')]), [6, new SimpleData('simpler')]) 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), 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, @@ -53,6 +61,8 @@ public function __construct( public DataCollection $dataCollectionWithAttribute, /** @var Collection */ public Collection $collectionWithUnion, + /** @var Collection|array */ + public EloquentCollection|array $collectionOrArrayWithUnion, /** @var array */ public array $arrayWithUnion, ) { From fc4fe634bdd6d96c2a53452a105fa744769af55c Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Fri, 24 Oct 2025 12:29:18 +0200 Subject: [PATCH 05/13] WIP:getting closer --- .../DataIterableAnnotationReader.php | 34 ++++++++++++++----- .../DataIterableAnnotationReaderTest.php | 2 +- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index 1d2bb8ef..f2621d97 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -62,7 +62,7 @@ protected function get( return []; } - $hasType = preg_match_all('/(?:@property(?:-read)?|@var|@param)\s*(.+)\s*(\$[a-zA-Z_][a-zA-Z0-9_]*)?.*$/uim', $comment, $matches); + $hasType = preg_match_all('/(?:@var|@param|@property(?:-read)?)\s+((?:\\\*[a-z_][a-z0-9_\-]*)*(?:\s*<\s*(?1)(?:\s*,\s*(?1))?\s*>)?(?:\s*\|\s*(?1))?)(?:\s*(\$[a-z_][a-z0-9_]*))?/im', $comment, $matches); if (! $hasType) { return []; } @@ -72,24 +72,37 @@ protected function get( $property = empty($matches[2][$index]) ? null : ltrim($matches[2][$index], '$'); $type = (new TypeResolver())->resolve($type); // , (new ContextFactory())->createFromReflector($reflection)); - /** @return Type[] */ - $commentTypeToDataStrings = function (Type $type) use (&$commentTypeToDataStrings): array { + $getOriginalKeyType = function(AbstractList $type): ?Type { + $list = new class($type) extends AbstractList { + public function __construct(AbstractList $victim) { + parent::__construct($victim->valueType, $victim->keyType); + } + public function getOriginalKeyType(): ?Type { + return $this->keyType; + } + }; + + return $list->getOriginalKeyType(); + }; + /** @return string[] */ + $commentToValueTypeStrings = function (Type $type, ?Type $key) use ($getOriginalKeyType, &$commentToValueTypeStrings): array { if ($type instanceof Compound) { - return array_merge(...array_map(fn (Type $t) => $commentTypeToDataStrings($t), iterator_to_array($type))); + return array_merge(...array_map(fn (Type $t) => $commentToValueTypeStrings($t, $key), iterator_to_array($type))); } elseif ($type instanceof AbstractList) { - return $commentTypeToDataStrings($type->getValueType()); + return $commentToValueTypeStrings($type->getValueType(), $getOriginalKeyType($type)); } elseif ($type instanceof Nullable) { - return $commentTypeToDataStrings($type->getActualType()); + return $commentToValueTypeStrings($type->getActualType(), $key); } else { - return [(string) $type]; + return [[(string) $type, $key === null ? 'array-key' : (string) $key]]; } }; - $typeStrings = $commentTypeToDataStrings($type); - foreach ($typeStrings as $typeString) { + $typeStrings = $commentToValueTypeStrings($type, null); + foreach ($typeStrings as [$typeString, $keyString]) { if (in_array($typeString, ['int', 'string', 'bool', 'float', 'array', 'object', 'callable', 'iterable', 'mixed'])) { $annotations[] = new DataIterableAnnotation( type: $typeString, isData: false, + keyType: $keyString, property: $property, ); @@ -101,6 +114,7 @@ protected function get( $annotations[] = new DataIterableAnnotation( type: $typeString, isData: true, + keyType: $keyString, property: $property, ); @@ -112,6 +126,7 @@ protected function get( $annotations[] = new DataIterableAnnotation( type: $fcqn, isData: is_subclass_of($fcqn, BaseData::class), + keyType: $keyString, property: $property, ); @@ -121,6 +136,7 @@ protected function get( $annotations[] = new DataIterableAnnotation( type: $typeString, isData: false, + keyType: $keyString, property: $property, ); } diff --git a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php index 1d75de16..2cf14a8c 100644 --- a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php +++ b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php @@ -263,7 +263,7 @@ function (string $property, ?DataIterableAnnotation $expected) { new ReflectionProperty($dataClass::class, 'collection') ); - expect($annotations)->toEqual(new DataIterableAnnotation(SimpleData::class, isData: true, keyType: 'array-key')); + expect($annotations)->toEqual(new DataIterableAnnotation(SimpleData::class, isData: true, keyType: 'int')); }); it('will recognize default PHP types', function (string $type) { From 16718cbe384457046ef354c80c18e80f5e6fecf6 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Fri, 24 Oct 2025 17:43:01 +0200 Subject: [PATCH 06/13] added full support for union types to typescript --- src/DataPipes/CastPropertiesDataPipe.php | 31 ++- .../DataIterableAnnotationReader.php | 123 ++++----- .../ArrayWithoutMixedDefault.php | 26 ++ .../IterableWithoutMixedDefault.php | 43 +++ src/Support/Factories/DataPropertyFactory.php | 4 +- src/Support/Factories/DataTypeFactory.php | 113 ++++---- .../CollectionNonDataAnnotationsData.php | 2 +- .../DataIterableAnnotationReaderTest.php | 254 ++++++++++++------ ...convert_a_data_object_to_Typescript__1.txt | 1 + 9 files changed, 387 insertions(+), 210 deletions(-) create mode 100644 src/Support/Annotations/PhpDocumentorTypes/ArrayWithoutMixedDefault.php create mode 100644 src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php diff --git a/src/DataPipes/CastPropertiesDataPipe.php b/src/DataPipes/CastPropertiesDataPipe.php index d3cbbb5e..b2eea8b5 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 f2621d97..d6ca589f 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -2,21 +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\Array_; use phpDocumentor\Reflection\Types\Compound; -use phpDocumentor\Reflection\Types\ContextFactory; 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\Types\Storage\AcceptedTypesStorage; +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 @@ -28,29 +28,26 @@ public function __construct( ) { } - /** @return array */ + /** @return array */ public function getForClass(ReflectionClass $class): array { - return collect($this->get($class))->reverse()->keyBy(fn (DataIterableAnnotation $annotation) => $annotation->property)->all(); - } - - public function getForProperty(ReflectionProperty $property): ?DataIterableAnnotation - { - $annotations = $this->get($property); - - return Arr::first($annotations, fn (DataIterableAnnotation $a) => $a->isData) ?? Arr::first($annotations); + return collect($this->get($class)) + ->groupBy(fn (DataIterableAnnotation $annotation) => $annotation->property) + ->toArray(); } /** @return DataIterableAnnotation[] */ - public function getAllForProperty(ReflectionProperty $property): array + public function getForProperty(ReflectionProperty $property): array { 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[] */ @@ -62,86 +59,92 @@ protected function get( return []; } - $hasType = preg_match_all('/(?:@var|@param|@property(?:-read)?)\s+((?:\\\*[a-z_][a-z0-9_\-]*)*(?:\s*<\s*(?1)(?:\s*,\s*(?1))?\s*>)?(?:\s*\|\s*(?1))?)(?:\s*(\$[a-z_][a-z0-9_]*))?/im', $comment, $matches); + // $hasType = preg_match_all('/(?:@var|@param|@property(?:-read)?)\s+(\??\s*(?:\\\*[a-z_][a-z0-9_\-]*)*(?:\s*<\s*(?1)(?:\s*,\s*(?1))?\s*>)?(?:\s*\[\s*\])?(?:\s*\|\s*(?1))?)(?:\s*(\$[a-z_][a-z0-9_]*))?/im', $comment, $matches); + $hasType = preg_match_all('/(?:@var|@param|@property(?:-read)?)(.+)/uim', $comment, $matches); if (! $hasType) { return []; } $annotations = []; - foreach ($matches[1] ?? [] as $index => $type) { - $property = empty($matches[2][$index]) ? null : ltrim($matches[2][$index], '$'); - $type = (new TypeResolver())->resolve($type); // , (new ContextFactory())->createFromReflector($reflection)); - - $getOriginalKeyType = function(AbstractList $type): ?Type { - $list = new class($type) extends AbstractList { - public function __construct(AbstractList $victim) { + foreach ($matches[1] as $match) { + [$valueTypeString, $propertyName] = explode('$', $match, 2) + [1 => null]; + $type = tap(new TypeResolver(), function (TypeResolver $t) { + $t->addKeyword('array', ArrayWithoutMixedDefault::class); + $t->addKeyword('iterable', IterableWithoutMixedDefault::class); + })->resolve($valueTypeString); + + $getOriginalKeyType = function (AbstractList $type): ?Type { + return(new class ($type) extends AbstractList { + public function __construct(AbstractList $victim) + { parent::__construct($victim->valueType, $victim->keyType); } - public function getOriginalKeyType(): ?Type { + public function getOriginalKeyType(): ?Type + { return $this->keyType; } - }; - - return $list->getOriginalKeyType(); + })->getOriginalKeyType(); }; /** @return string[] */ - $commentToValueTypeStrings = function (Type $type, ?Type $key) use ($getOriginalKeyType, &$commentToValueTypeStrings): array { + $getValueTypeStrings = function (Type $type, ?Type $key) use ($getOriginalKeyType, &$getValueTypeStrings): array { if ($type instanceof Compound) { - return array_merge(...array_map(fn (Type $t) => $commentToValueTypeStrings($t, $key), iterator_to_array($type))); + return array_merge(...array_map(fn (Type $t) => $getValueTypeStrings($t, $key), iterator_to_array($type))); + } elseif ($type instanceof ArrayWithoutMixedDefault || $type instanceof IterableWithoutMixedDefault) { + // If is array without `<>` definition (defaults to mixed), then cast to array, not mixed. + return $type->getOriginalValueType() === null + ? [[(string) $type, $key === null ? 'array-key' : (string) $key]] + : $getValueTypeStrings($type->getOriginalValueType(), $getOriginalKeyType($type)); } elseif ($type instanceof AbstractList) { - return $commentToValueTypeStrings($type->getValueType(), $getOriginalKeyType($type)); + return $getValueTypeStrings($type->getValueType(), $getOriginalKeyType($type)); } elseif ($type instanceof Nullable) { - return $commentToValueTypeStrings($type->getActualType(), $key); + return $getValueTypeStrings($type->getActualType(), $key); } else { return [[(string) $type, $key === null ? 'array-key' : (string) $key]]; } }; - $typeStrings = $commentToValueTypeStrings($type, null); - foreach ($typeStrings as [$typeString, $keyString]) { - if (in_array($typeString, ['int', 'string', 'bool', 'float', 'array', 'object', 'callable', 'iterable', 'mixed'])) { + $valueTypeStrings = $getValueTypeStrings($type, null); + foreach ($valueTypeStrings as [$valueTypeString, $keyString]) { + $valueTypeString = ltrim($valueTypeString, '\\'); + if (is_subclass_of($valueTypeString, BaseData::class)) { $annotations[] = new DataIterableAnnotation( - type: $typeString, - isData: false, + type: $valueTypeString, + isData: true, keyType: $keyString, - property: $property, + property: $propertyName, ); continue; } - $typeString = ltrim($typeString, '\\'); - if (is_subclass_of($typeString, BaseData::class)) { - $annotations[] = new DataIterableAnnotation( - type: $typeString, - isData: true, - keyType: $keyString, - property: $property, - ); + static $ignoredClasses = [Lazy::class, Optional::class]; + + $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, + ); + } continue; } - $fcqn = $this->resolveFcqn($reflection, $typeString); - if (class_exists($fcqn)) { + if (! in_array($valueTypeString, $ignoredClasses) && ! array_any($ignoredClasses, fn ($ignoredClass) => is_subclass_of($valueTypeString, $ignoredClass))) { $annotations[] = new DataIterableAnnotation( - type: $fcqn, - isData: is_subclass_of($fcqn, BaseData::class), + type: $valueTypeString, + isData: false, keyType: $keyString, - property: $property, + property: $propertyName, ); - - continue; } - - $annotations[] = new DataIterableAnnotation( - type: $typeString, - isData: false, - keyType: $keyString, - property: $property, - ); } } + usort($annotations, fn (DataIterableAnnotation $a, DataIterableAnnotation $b) => $b->isData <=> $a->isData); + return $annotations; } diff --git a/src/Support/Annotations/PhpDocumentorTypes/ArrayWithoutMixedDefault.php b/src/Support/Annotations/PhpDocumentorTypes/ArrayWithoutMixedDefault.php new file mode 100644 index 00000000..d8792f20 --- /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 00000000..5d19d052 --- /dev/null +++ b/src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php @@ -0,0 +1,43 @@ +originalValueType = $valueType; + } + + public function getOriginalValueType(): ?Type + { + return $this->originalValueType; + } + + 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/Factories/DataPropertyFactory.php b/src/Support/Factories/DataPropertyFactory.php index 74cb5866..17f39523 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 67e59219..6ab78265 100644 --- a/src/Support/Factories/DataTypeFactory.php +++ b/src/Support/Factories/DataTypeFactory.php @@ -41,14 +41,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 +77,7 @@ public function build( class: $class, typeable: $typeable, attributes: null, - classDefinedDataIterableAnnotation: null, + classDefinedDataIterableAnnotations: null, inferForProperty: false, ); @@ -101,7 +101,7 @@ public function buildFromString( class: $class, typeable: $type, attributes: null, - classDefinedDataIterableAnnotation: null, + classDefinedDataIterableAnnotations: null, inferForProperty: false, ); @@ -132,7 +132,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 +145,7 @@ protected function infer( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty, ); } @@ -156,7 +156,7 @@ protected function infer( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty, ); } @@ -225,7 +225,7 @@ protected function inferPropertiesForSingleType( ReflectionClass|string $class, ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, ?DataAttributesCollection $attributes, - ?DataIterableAnnotation $classDefinedDataIterableAnnotation, + ?array $classDefinedDataIterableAnnotations, bool $inferForProperty, ): array { return [ @@ -235,7 +235,7 @@ protected function inferPropertiesForSingleType( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty, ), 'isOptional' => false, @@ -261,7 +261,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,61 +331,32 @@ 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 - && $annotations = $this->iterableAnnotationReader->getAllForProperty($typeable) + && !empty($annotations = $this->iterableAnnotationReader->getForProperty($typeable)) ) { if (count($annotations) == 1) { $isData = $annotations[0]->isData; $iterableItemType = $annotations[0]->type; $iterableKeyType = $annotations[0]->keyType; - } elseif (count($annotations) > 1) { - // $isData = false; - // $dataClass = null; - // $dataCollectableClass = null; - // foreach ($annotations as $annotation) { - // if ($annotation->isData) { - // $isData = true; - // $dataClass = $annotation->type; - // $dataCollectableClass = $name; - // break; - // } - // } - $iterableItemType = join('|', array_unique(array_column($annotations, 'type'))); - $iterableKeyType = join('|', array_unique(array_column($annotations, 'keyType'))); - return [ - 'type' => new UnionType( - array_map( - fn (DataIterableAnnotation $annotation) => new NamedType( - name: $name, - builtIn: $builtIn, - acceptedTypes: $acceptedTypes, - kind: $annotation->isData ? $kind->getDataRelatedEquivalent() : $kind, - dataClass: $annotation->isData ? $annotation->type : null, - dataCollectableClass: $annotation->isData ? $name : null, - iterableClass: $name, - iterableItemType: $annotation->type, - iterableKeyType: $annotation->keyType, - ), - $annotations, - ) - ), - 'isMixed' => $isMixed, - 'kind' => $kind, // $isData ? $kind->getDataRelatedEquivalent() : $kind, - 'dataClass' => null, // $dataClass, - 'dataCollectableClass' => null, // $dataCollectableClass, - 'iterableClass' => $name, - 'iterableItemType' => $iterableItemType, - 'iterableKeyType' => $iterableKeyType, - ]; + } else { + $isData = array_column($annotations, 'isData'); + $iterableItemType = array_column($annotations, 'type'); + $iterableKeyType = array_column($annotations, 'keyType'); } } @@ -400,6 +371,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, // $isData ? $kind->getDataRelatedEquivalent() : $kind, + 'dataClass' => null, // $dataClass, + 'dataCollectableClass' => null, // $dataCollectableClass, + 'iterableClass' => $name, + 'iterableItemType' => join('|', array_unique($iterableItemType)), + 'iterableKeyType' => join('|', array_unique($iterableKeyType)), + ]; + } + $kind = $isData ? $kind->getDataRelatedEquivalent() : $kind; @@ -449,7 +448,7 @@ protected function inferPropertiesForCombinationType( ReflectionClass|string $class, ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, ?DataAttributesCollection $attributes, - ?DataIterableAnnotation $classDefinedDataIterableAnnotation, + ?array $classDefinedDataIterableAnnotations, bool $inferForProperty, ): array { $isMixed = false; @@ -473,7 +472,7 @@ protected function inferPropertiesForCombinationType( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty ); @@ -518,7 +517,7 @@ protected function inferPropertiesForCombinationType( $class, $typeable, $attributes, - $classDefinedDataIterableAnnotation, + $classDefinedDataIterableAnnotations, $inferForProperty ); diff --git a/tests/Fakes/CollectionNonDataAnnotationsData.php b/tests/Fakes/CollectionNonDataAnnotationsData.php index 1e858f7f..997d0df3 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 2cf14a8c..7a0a8e7e 100644 --- a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php +++ b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php @@ -13,7 +13,7 @@ it( 'can get the data class for a data collection by annotation', - function (string $property, ?DataIterableAnnotation $expected) { + function (string $property, array $expected) { $annotations = app(DataIterableAnnotationReader::class)->getForProperty(new ReflectionProperty(CollectionDataAnnotationsData::class, $property)); expect($annotations)->toEqual($expected); @@ -21,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 - new DataIterableAnnotation(SimpleData::class, isData: true), // 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); @@ -147,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'), + ], ]); }); @@ -257,13 +349,19 @@ function (string $property, ?DataIterableAnnotation $expected) { 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')); + 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) { @@ -300,7 +398,7 @@ function (string $property, ?DataIterableAnnotation $expected) { app(DataIterableAnnotationReader::class)->getForProperty( new ReflectionProperty($dataClass::class, $type) ) - )->toEqual(new DataIterableAnnotation(str_replace(['array', 'iterable'], 'mixed', $type), isData: false)); + )->toEqual([new DataIterableAnnotation($type, isData: false)]); })->with([ 'string' => ['string'], 'int' => ['int'], @@ -341,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/__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 7068232a..ef284918 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 @@ -13,5 +13,6 @@ dataCollection: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; dataCollectionAlternative: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; 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 From 2ee9c4c18f4539e60fb7978785f872203b8baf37 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Fri, 24 Oct 2025 17:55:28 +0200 Subject: [PATCH 07/13] general code cleanup --- .../DataIterableAnnotationReader.php | 17 +++++++++-------- .../IterableWithoutMixedDefault.php | 4 +++- src/Support/Factories/DataTypeFactory.php | 11 +++++------ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index d6ca589f..4a58ddc2 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -59,7 +59,6 @@ protected function get( return []; } - // $hasType = preg_match_all('/(?:@var|@param|@property(?:-read)?)\s+(\??\s*(?:\\\*[a-z_][a-z0-9_\-]*)*(?:\s*<\s*(?1)(?:\s*,\s*(?1))?\s*>)?(?:\s*\[\s*\])?(?:\s*\|\s*(?1))?)(?:\s*(\$[a-z_][a-z0-9_]*))?/im', $comment, $matches); $hasType = preg_match_all('/(?:@var|@param|@property(?:-read)?)(.+)/uim', $comment, $matches); if (! $hasType) { return []; @@ -68,40 +67,42 @@ protected function get( $annotations = []; foreach ($matches[1] as $match) { [$valueTypeString, $propertyName] = explode('$', $match, 2) + [1 => null]; + $type = tap(new TypeResolver(), function (TypeResolver $t) { $t->addKeyword('array', ArrayWithoutMixedDefault::class); $t->addKeyword('iterable', IterableWithoutMixedDefault::class); })->resolve($valueTypeString); - $getOriginalKeyType = function (AbstractList $type): ?Type { + $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 getOriginalKeyType(): ?Type + public function getKeyTypeWithoutDefault(): ?Type { return $this->keyType; } - })->getOriginalKeyType(); + })->getKeyTypeWithoutDefault(); }; + /** @return string[] */ - $getValueTypeStrings = function (Type $type, ?Type $key) use ($getOriginalKeyType, &$getValueTypeStrings): array { + $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) { - // If is array without `<>` definition (defaults to mixed), then cast to array, not mixed. return $type->getOriginalValueType() === null ? [[(string) $type, $key === null ? 'array-key' : (string) $key]] - : $getValueTypeStrings($type->getOriginalValueType(), $getOriginalKeyType($type)); + : $getValueTypeStrings($type->getOriginalValueType(), $getKeyTypeWithoutDefault($type)); } elseif ($type instanceof AbstractList) { - return $getValueTypeStrings($type->getValueType(), $getOriginalKeyType($type)); + 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, '\\'); diff --git a/src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php b/src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php index 5d19d052..3236ab39 100644 --- a/src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php +++ b/src/Support/Annotations/PhpDocumentorTypes/IterableWithoutMixedDefault.php @@ -4,10 +4,11 @@ use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\AbstractList; +use phpDocumentor\Reflection\Types\Iterable_; use phpDocumentor\Reflection\Types\Mixed_; /** - * Can't extend {@see \phpDocumentor\Reflection\Types\Iterable_} because it's final. + * Can't extend {@see Iterable_} because it's final. */ class IterableWithoutMixedDefault extends AbstractList { @@ -28,6 +29,7 @@ public function getOriginalValueType(): ?Type return $this->originalValueType; } + /** Based on {@see Iterable_::__toString()}. */ public function __toString(): string { if ($this->keyType) { diff --git a/src/Support/Factories/DataTypeFactory.php b/src/Support/Factories/DataTypeFactory.php index 6ab78265..491d704c 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; @@ -331,7 +330,7 @@ protected function inferPropertiesForNamedType( if ( $iterableItemType === null - && !empty($classDefinedDataIterableAnnotations) + && ! empty($classDefinedDataIterableAnnotations) ) { if (count($classDefinedDataIterableAnnotations) == 1) { $isData = $classDefinedDataIterableAnnotations[0]->isData; @@ -347,7 +346,7 @@ protected function inferPropertiesForNamedType( if ( $iterableItemType === null && $typeable instanceof ReflectionProperty - && !empty($annotations = $this->iterableAnnotationReader->getForProperty($typeable)) + && ! empty($annotations = $this->iterableAnnotationReader->getForProperty($typeable)) ) { if (count($annotations) == 1) { $isData = $annotations[0]->isData; @@ -390,9 +389,9 @@ protected function inferPropertiesForNamedType( return [ 'type' => new UnionType($types), 'isMixed' => $isMixed, - 'kind' => $kind, // $isData ? $kind->getDataRelatedEquivalent() : $kind, - 'dataClass' => null, // $dataClass, - 'dataCollectableClass' => null, // $dataCollectableClass, + 'kind' => $kind, + 'dataClass' => null, + 'dataCollectableClass' => null, 'iterableClass' => $name, 'iterableItemType' => join('|', array_unique($iterableItemType)), 'iterableKeyType' => join('|', array_unique($iterableKeyType)), From f4fbe69a143093e76fe43980ea75e1bb77906705 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Fri, 24 Oct 2025 18:02:40 +0200 Subject: [PATCH 08/13] correctly handling property description --- src/Support/Annotations/DataIterableAnnotationReader.php | 1 + tests/Fakes/CollectionDataAnnotationsData.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index 4a58ddc2..afedc168 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -67,6 +67,7 @@ protected function get( $annotations = []; foreach ($matches[1] as $match) { [$valueTypeString, $propertyName] = explode('$', $match, 2) + [1 => null]; + $propertyName === null or $propertyName = explode(' ', $propertyName, 2)[0]; $type = tap(new TypeResolver(), function (TypeResolver $t) { $t->addKeyword('array', ArrayWithoutMixedDefault::class); diff --git a/tests/Fakes/CollectionDataAnnotationsData.php b/tests/Fakes/CollectionDataAnnotationsData.php index 255dad04..530455cd 100644 --- a/tests/Fakes/CollectionDataAnnotationsData.php +++ b/tests/Fakes/CollectionDataAnnotationsData.php @@ -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 From 1146372379ab2b56dc7bc809bc6b3d812dd83502 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Sat, 25 Oct 2025 14:04:58 +0200 Subject: [PATCH 09/13] better split in case of no space --- src/Support/Annotations/DataIterableAnnotationReader.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index afedc168..8816d40c 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -67,7 +67,8 @@ protected function get( $annotations = []; foreach ($matches[1] as $match) { [$valueTypeString, $propertyName] = explode('$', $match, 2) + [1 => null]; - $propertyName === null or $propertyName = explode(' ', $propertyName, 2)[0]; + // 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); From aab2907f2c91f4cd9715e6b9d66f91cecd966a09 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Thu, 30 Oct 2025 15:05:08 +0100 Subject: [PATCH 10/13] also allow union on datacollection --- src/Support/Factories/DataTypeFactory.php | 2 +- .../DataTypeScriptTransformer.php | 14 +++++++++++++- .../DataTypeScriptTransformerTest.php | 4 +++- ..._can_convert_a_data_object_to_Typescript__1.txt | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Support/Factories/DataTypeFactory.php b/src/Support/Factories/DataTypeFactory.php index 491d704c..c43694f6 100644 --- a/src/Support/Factories/DataTypeFactory.php +++ b/src/Support/Factories/DataTypeFactory.php @@ -390,7 +390,7 @@ protected function inferPropertiesForNamedType( 'type' => new UnionType($types), 'isMixed' => $isMixed, 'kind' => $kind, - 'dataClass' => null, + 'dataClass' => join('|', array_unique($iterableItemType)), 'dataCollectableClass' => null, 'iterableClass' => $name, 'iterableItemType' => join('|', array_unique($iterableItemType)), diff --git a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php index 27208836..99034411 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/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php index 024802a2..6a5bab23 100644 --- a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php +++ b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php @@ -40,7 +40,7 @@ function assertMatchesSnapshot($actual, ?Driver $driver = null): void ]; }; - $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), collect([6, new SimpleData('simp')]), new EloquentCollection([$foo]), [6, new SimpleData('simpler')]) extends Data { + $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, @@ -57,6 +57,8 @@ 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 */ 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 ef284918..259971e1 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,6 +11,7 @@ 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; From 3e15bdd2e4717905e85f92fe666cd41a537e205e Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Thu, 30 Oct 2025 15:17:56 +0100 Subject: [PATCH 11/13] grab data class when union --- src/Support/DataConfig.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Support/DataConfig.php b/src/Support/DataConfig.php index 946bdc87..f127bb83 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)); } From 4037449c33a309256e20141c1939c665b6b32dea Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Wed, 19 Nov 2025 12:05:23 +0100 Subject: [PATCH 12/13] added array shape transform --- src/Support/Annotations/DataIterableAnnotationReader.php | 6 +++++- .../TypeScriptTransformer/DataTypeScriptTransformerTest.php | 4 +++- ...rTest__it_can_convert_a_data_object_to_Typescript__1.txt | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index 8816d40c..83926fda 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -121,7 +121,11 @@ public function getKeyTypeWithoutDefault(): ?Type static $ignoredClasses = [Lazy::class, Optional::class]; - $fcqn = $this->resolveFcqn($reflection, $valueTypeString); + 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( diff --git a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php index 6a5bab23..2dac8f5c 100644 --- a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php +++ b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php @@ -40,7 +40,7 @@ function assertMatchesSnapshot($actual, ?Driver $driver = null): void ]; }; - $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 { + $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')], [['id'=>1, 'name'=>'Hank']]) extends Data { public function __construct( public null|int $nullable, public Optional|int $undefineable, @@ -67,6 +67,8 @@ public function __construct( public EloquentCollection|array $collectionOrArrayWithUnion, /** @var array */ public array $arrayWithUnion, + /** @var array{id: int, name: string}[] Note `array{id: int, name: string}[]` does not work */ + public array $arrayWithStruct, ) { } }; 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 259971e1..ab22d355 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 @@ -16,4 +16,5 @@ 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>; +arrayWithStruct: Array<{id:number;name:string;}>; } \ No newline at end of file From 2f953138b1aa6dfc4a81b611048c3fab6f6955a9 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Wed, 19 Nov 2025 12:07:43 +0100 Subject: [PATCH 13/13] fix bad comment --- .../TypeScriptTransformer/DataTypeScriptTransformerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php index 2dac8f5c..3842df0c 100644 --- a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php +++ b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php @@ -67,7 +67,7 @@ public function __construct( public EloquentCollection|array $collectionOrArrayWithUnion, /** @var array */ public array $arrayWithUnion, - /** @var array{id: int, name: string}[] Note `array{id: int, name: string}[]` does not work */ + /** @var array{id: int, name: string}[] Could also be `array` */ public array $arrayWithStruct, ) { }