diff --git a/docs/as-a-data-transfer-object/nesting.md b/docs/as-a-data-transfer-object/nesting.md index a4bc48f7..3b7e98af 100644 --- a/docs/as-a-data-transfer-object/nesting.md +++ b/docs/as-a-data-transfer-object/nesting.md @@ -124,6 +124,29 @@ class AlbumData extends Data } ``` +If the collection is well-annotated, the `Data` class doesn't need to use annotations: + +```php +/** + * @template TKey of array-key + * @template TData of \App\Data\SongData + * + * @extends \Illuminate\Support\Collection + */ +class SongDataCollection extends Collection +{ +} + +class AlbumData extends Data +{ + public function __construct( + public string $title, + public SongDataCollection $songs, + ) { + } +} +``` + You can also use an attribute to define the type of data objects that will be stored within a collection: ```php diff --git a/src/LaravelDataServiceProvider.php b/src/LaravelDataServiceProvider.php index b5871384..748fe8db 100644 --- a/src/LaravelDataServiceProvider.php +++ b/src/LaravelDataServiceProvider.php @@ -6,6 +6,7 @@ use Spatie\LaravelData\Commands\DataMakeCommand; use Spatie\LaravelData\Commands\DataStructuresCacheCommand; use Spatie\LaravelData\Contracts\BaseData; +use Spatie\LaravelData\Resolvers\ContextResolver; use Spatie\LaravelData\Support\Caching\DataStructureCache; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\Livewire\LivewireDataCollectionSynth; @@ -43,6 +44,8 @@ function () { } ); + $this->app->singleton(ContextResolver::class); + $this->app->beforeResolving(BaseData::class, function ($class, $parameters, $app) { if ($app->has($class)) { return; diff --git a/src/Resolvers/ContextResolver.php b/src/Resolvers/ContextResolver.php new file mode 100644 index 00000000..d82c0f6d --- /dev/null +++ b/src/Resolvers/ContextResolver.php @@ -0,0 +1,24 @@ + */ + protected array $contexts = []; + + public function execute(ReflectionProperty|ReflectionClass|ReflectionMethod $reflection): Context + { + $reflectionClass = $reflection instanceof ReflectionProperty || $reflection instanceof ReflectionMethod + ? $reflection->getDeclaringClass() + : $reflection; + + return $this->contexts[$reflectionClass->getName()] ??= (new ContextFactory())->createFromReflector($reflectionClass); + } +} diff --git a/src/Support/Annotations/CollectionAnnotation.php b/src/Support/Annotations/CollectionAnnotation.php new file mode 100644 index 00000000..8be726bb --- /dev/null +++ b/src/Support/Annotations/CollectionAnnotation.php @@ -0,0 +1,13 @@ + */ + protected static array $cache = []; + + protected Context $context; + + /** + * @param class-string $className + */ + public function getForClass(string $className): ?CollectionAnnotation + { + // Check the cache first + if (array_key_exists($className, self::$cache)) { + return self::$cache[$className]; + } + + // Create ReflectionClass from class string + $class = $this->getReflectionClass($className); + + // Determine if the class is a collection + if (! $this->isCollection($class)) { + return self::$cache[$className] = null; + } + + // Get the collection return type + $type = $this->getCollectionReturnType($class); + + if ($type === null || $type['valueType'] === null) { + return self::$cache[$className] = null; + } + + $isData = is_subclass_of($type['valueType'], Data::class); + + $annotation = new CollectionAnnotation( + type: $type['valueType'], + isData: $isData, + keyType: $type['keyType'] ?? 'array-key', + ); + + // Cache the result + self::$cache[$className] = $annotation; + + return $annotation; + } + + public static function clearCache(): void + { + self::$cache = []; + } + + /** + * @param class-string $className + */ + protected function getReflectionClass(string $className): ReflectionClass + { + return new ReflectionClass($className); + } + + protected function isCollection(ReflectionClass $class): bool + { + // Check if the class implements common collection interfaces + $collectionInterfaces = [ + Iterator::class, + IteratorAggregate::class, + ]; + + foreach ($collectionInterfaces as $interface) { + if ($class->implementsInterface($interface)) { + return true; + } + } + + return false; + } + + /** + * @return array{keyType: string|null, valueType: string|null}|null + */ + protected function getCollectionReturnType(ReflectionClass $class): ?array + { + // Initialize TypeResolver and DocBlockFactory + $docBlockFactory = DocBlockFactory::createInstance(); + + $this->context = $this->contextResolver->execute($class); + + // Get the PHPDoc comment of the class + $docComment = $class->getDocComment(); + if ($docComment === false) { + return null; + } + + // Create the DocBlock instance + $docBlock = $docBlockFactory->create($docComment, $this->context); + + // Initialize variables + $templateTypes = []; + $keyType = null; + $valueType = null; + + foreach ($docBlock->getTags() as $tag) { + + if (! $tag instanceof Generic) { + continue; + } + + if ($tag->getName() === 'template') { + $description = $tag->getDescription(); + + if (preg_match('/^(\w+)\s+of\s+([^\s]+)/', $description, $matches)) { + $templateTypes[$matches[1]] = $this->resolve($matches[2]); + } + + continue; + } + + if ($tag->getName() === 'extends') { + $description = $tag->getDescription(); + + if (preg_match('/<\s*([^,\s]+)?\s*(?:,\s*([^>\s]+))?\s*>/', $description, $matches)) { + + if (count($matches) === 3) { + $keyType = $templateTypes[$matches[1]] ?? $this->resolve($matches[1]); + $valueType = $templateTypes[$matches[2]] ?? $this->resolve($matches[2]); + } else { + $keyType = null; + $valueType = $templateTypes[$matches[1]] ?? $this->resolve($matches[1]); + } + + $keyType = $keyType ? explode('|', $keyType)[0] : null; + $valueType = explode('|', $valueType)[0]; + + return [ + 'keyType' => $keyType, + 'valueType' => $valueType, + ]; + } + } + } + + return null; + } + + protected function resolve(string $type): ?string + { + $type = (string) $this->typeResolver->resolve($type, $this->context); + + return $type ? ltrim($type, '\\') : null; + } +} diff --git a/src/Support/Annotations/DataIterableAnnotationReader.php b/src/Support/Annotations/DataIterableAnnotationReader.php index c3a63308..61545c6e 100644 --- a/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/Support/Annotations/DataIterableAnnotationReader.php @@ -4,20 +4,21 @@ use Illuminate\Support\Arr; use phpDocumentor\Reflection\FqsenResolver; -use phpDocumentor\Reflection\Types\Context; -use phpDocumentor\Reflection\Types\ContextFactory; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; use Spatie\LaravelData\Contracts\BaseData; +use Spatie\LaravelData\Resolvers\ContextResolver; /** * @note To myself, always use the fully qualified class names in pest tests when using anonymous classes */ class DataIterableAnnotationReader { - /** @var array */ - protected static array $contexts = []; + public function __construct( + protected readonly ContextResolver $contextResolver, + ) { + } /** @return array */ public function getForClass(ReflectionClass $class): array @@ -196,19 +197,10 @@ protected function resolveFcqn( ReflectionProperty|ReflectionClass|ReflectionMethod $reflection, string $class ): ?string { - $context = $this->getContext($reflection); + $context = $this->contextResolver->execute($reflection); $type = (new FqsenResolver())->resolve($class, $context); return ltrim((string) $type, '\\'); } - - protected function getContext(ReflectionProperty|ReflectionClass|ReflectionMethod $reflection): Context - { - $reflectionClass = $reflection instanceof ReflectionProperty || $reflection instanceof ReflectionMethod - ? $reflection->getDeclaringClass() - : $reflection; - - return static::$contexts[$reflectionClass->getName()] ??= (new ContextFactory())->createFromReflector($reflectionClass); - } } diff --git a/src/Support/Factories/DataTypeFactory.php b/src/Support/Factories/DataTypeFactory.php index d062f92f..cc9e294a 100644 --- a/src/Support/Factories/DataTypeFactory.php +++ b/src/Support/Factories/DataTypeFactory.php @@ -17,6 +17,7 @@ use Spatie\LaravelData\Exceptions\CannotFindDataClass; 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\DataPropertyType; @@ -36,6 +37,7 @@ class DataTypeFactory { public function __construct( protected DataIterableAnnotationReader $iterableAnnotationReader, + protected CollectionAnnotationReader $collectionAnnotationReader, ) { } @@ -354,6 +356,17 @@ protected function inferPropertiesForNamedType( $iterableKeyType = $annotation->keyType; } + if ( + $iterableItemType === null + && $typeable instanceof ReflectionProperty + && class_exists($name) + && $annotation = $this->collectionAnnotationReader->getForClass($name) + ) { + $isData = $annotation->isData; + $iterableItemType = $annotation->type; + $iterableKeyType = $annotation->keyType; + } + $kind = $isData ? $kind->getDataRelatedEquivalent() : $kind; diff --git a/tests/CollectionAttributeWithAnotationsTest.php b/tests/CollectionAttributeWithAnotationsTest.php new file mode 100644 index 00000000..af001b14 --- /dev/null +++ b/tests/CollectionAttributeWithAnotationsTest.php @@ -0,0 +1,49 @@ +payload = [ + 'collection' => [ + ['string' => 'string1'], + ['string' => 'string2'], + ['string' => 'string3'], + ], + ]; +}); + +it('can create a data object with a collection attribute from array and back', function () { + + $data = DataWithSimpleDataCollectionWithAnotations::from($this->payload); + + expect($data)->toEqual(new DataWithSimpleDataCollectionWithAnotations( + collection: new SimpleDataCollectionWithAnotations([ + new SimpleData(string: 'string1'), + new SimpleData(string: 'string2'), + new SimpleData(string: 'string3'), + ]) + )); + + expect($data->toArray())->toBe($this->payload); +}); + +it('can validate a data object with a collection attribute', function () { + + DataValidationAsserter::for(DataWithSimpleDataCollectionWithAnotations::class) + ->assertOk($this->payload) + ->assertErrors(['collection' => [ + ['notExistingAttribute' => 'xxx'], + ]]) + ->assertRules( + rules: [ + 'collection' => ['present', 'array'], + 'collection.0.string' => ['required', 'string'], + 'collection.1.string' => ['required', 'string'], + 'collection.2.string' => ['required', 'string'], + ], + payload: $this->payload + ); +}); diff --git a/tests/Fakes/Collections/SimpleDataCollectionWithAnotations.php b/tests/Fakes/Collections/SimpleDataCollectionWithAnotations.php new file mode 100644 index 00000000..84385a63 --- /dev/null +++ b/tests/Fakes/Collections/SimpleDataCollectionWithAnotations.php @@ -0,0 +1,15 @@ + + */ +class SimpleDataCollectionWithAnotations extends Collection +{ +} diff --git a/tests/Fakes/DataWithSimpleDataCollectionWithAnotations.php b/tests/Fakes/DataWithSimpleDataCollectionWithAnotations.php new file mode 100644 index 00000000..8116ac5e --- /dev/null +++ b/tests/Fakes/DataWithSimpleDataCollectionWithAnotations.php @@ -0,0 +1,14 @@ +execute($reflectionProperty); + + // Create expected context + $expectedContext = (new ContextFactory())->createFromReflector($reflectionProperty->getDeclaringClass()); + + // Assertions + expect($context)->toBeInstanceOf(Context::class); + expect($context)->toEqual($expectedContext); +}); + +it('can resolve context from class', function () { + $resolver = new ContextResolver(); + + // Create a ReflectionClass for the test class + $reflectionClass = new ReflectionClass(TestContextResolverClass::class); + + // Resolve the context + $context = $resolver->execute($reflectionClass); + + // Create expected context + $expectedContext = (new ContextFactory())->createFromReflector($reflectionClass); + + // Assertions + expect($context)->toBeInstanceOf(Context::class); + expect($context)->toEqual($expectedContext); +}); + +it('can resolve context from method', function () { + $resolver = new ContextResolver(); + + // Create a ReflectionMethod for the test class method + $reflectionMethod = new ReflectionMethod(TestContextResolverClass::class, 'testMethod'); + + // Resolve the context + $context = $resolver->execute($reflectionMethod); + + // Create expected context + $expectedContext = (new ContextFactory())->createFromReflector($reflectionMethod->getDeclaringClass()); + + // Assertions + expect($context)->toBeInstanceOf(Context::class); + expect($context)->toEqual($expectedContext); +}); + +it('uses cache when resolving the same class multiple times', function () { + $resolver = new ContextResolver(); + + // Create a ReflectionClass for the test class + $reflectionClass = new ReflectionClass(TestContextResolverClass::class); + + // Resolve the context the first time + $context1 = $resolver->execute($reflectionClass); + + // Resolve the context the second time + $context2 = $resolver->execute($reflectionClass); + + // Assertions + expect($context1)->toBeInstanceOf(Context::class); + expect($context2)->toBeInstanceOf(Context::class); + expect($context1)->toBe($context2); // They should be the same instance, indicating the cache was used +}); + +// Test class +class TestContextResolverClass +{ + public $testProperty; + + public function testMethod() + { + } +} diff --git a/tests/Support/Annotations/CollectionAnnotationReaderTest.php b/tests/Support/Annotations/CollectionAnnotationReaderTest.php new file mode 100644 index 00000000..18be76d9 --- /dev/null +++ b/tests/Support/Annotations/CollectionAnnotationReaderTest.php @@ -0,0 +1,275 @@ +getForClass($className); + + expect($annotations)->toEqual($expected); + } +)->with(function () { + yield DataCollectionWithTemplate::class => [ + 'className' => DataCollectionWithTemplate::class, + 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true), + ]; + + yield DataCollectionWithoutTemplate::class => [ + 'className' => DataCollectionWithoutTemplate::class, + 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true), + ]; + + yield DataCollectionWithCombinationType::class => [ + 'className' => DataCollectionWithCombinationType::class, + 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true), + ]; + + yield DataCollectionWithIntegerKey::class => [ + 'className' => DataCollectionWithIntegerKey::class, + 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true, keyType: 'int'), + ]; + + yield DataCollectionWithCombinationKey::class => [ + 'className' => DataCollectionWithCombinationKey::class, + 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true, keyType: 'int'), + ]; + + yield DataCollectionWithoutKey::class => [ + 'className' => DataCollectionWithoutKey::class, + 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true), + ]; + + yield NonDataCollectionWithTemplate::class => [ + 'className' => NonDataCollectionWithTemplate::class, + 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + ]; + + yield NonDataCollectionWithoutTemplate::class => [ + 'className' => NonDataCollectionWithoutTemplate::class, + 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + ]; + + yield NonDataCollectionWithCombinationType::class => [ + 'className' => NonDataCollectionWithCombinationType::class, + 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + ]; + + yield NonDataCollectionWithIntegerKey::class => [ + 'className' => NonDataCollectionWithIntegerKey::class, + 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false, keyType: 'int'), + ]; + + yield NonDataCollectionWithCombinationKey::class => [ + 'className' => NonDataCollectionWithCombinationKey::class, + 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false, keyType: 'int'), + ]; + + yield NonDataCollectionWithoutKey::class => [ + 'className' => NonDataCollectionWithoutKey::class, + 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + ]; + + yield CollectionWhoImplementsIterator::class => [ + 'className' => CollectionWhoImplementsIterator::class, + 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + ]; + + yield CollectionWhoImplementsIteratorAggregate::class => [ + 'className' => CollectionWhoImplementsIteratorAggregate::class, + 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + ]; + + yield CollectionWhoImplementsNothing::class => [ + 'className' => CollectionWhoImplementsNothing::class, + 'expected' => null, + ]; + + yield CollectionWithoutDocBlock::class => [ + 'className' => CollectionWithoutDocBlock::class, + 'expected' => null, + ]; + + yield CollectionWithoutType::class => [ + 'className' => CollectionWithoutType::class, + 'expected' => null, + ]; +}); + +it('can caches the result', function (string $className) { + + // Create a partial mock + $collectionAnnotationReader = Mockery::spy(CollectionAnnotationReader::class, [ + app(ContextResolver::class), + app(TypeResolver::class), + ])->makePartial(); + + // Call the getForClass method with a test class + $collectionAnnotation = $collectionAnnotationReader->getForClass($className); + + // Call the getForClass method again to test caching + $cachedCollectionAnnotation = $collectionAnnotationReader->getForClass($className); + + // Assert the cache is used and the same annotation is returned + expect($cachedCollectionAnnotation)->toBe($collectionAnnotation); + + // Check if getReflectionClass was called only once + $collectionAnnotationReader->shouldHaveReceived('getReflectionClass')->once(); + +})->with([ + [CollectionWhoImplementsNothing::class], // first return + [CollectionWithoutDocBlock::class], // second return + [DataCollectionWithTemplate::class], // third return +]); + +/** + * @template TKey of array-key + * @template TData of \Spatie\LaravelData\Tests\Fakes\SimpleData + * + * @extends \Illuminate\Support\Collection + */ +class DataCollectionWithTemplate extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class DataCollectionWithoutTemplate extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class DataCollectionWithCombinationType extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class DataCollectionWithIntegerKey extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class DataCollectionWithCombinationKey extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection<\Spatie\LaravelData\Tests\Fakes\SimpleData> + */ +class DataCollectionWithoutKey extends Collection +{ +} + +/** + * @template TKey of array-key + * @template TValue of \Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum + * + * @extends \Illuminate\Support\Collection + */ +class NonDataCollectionWithTemplate extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class NonDataCollectionWithoutTemplate extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class NonDataCollectionWithCombinationType extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class NonDataCollectionWithIntegerKey extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class NonDataCollectionWithCombinationKey extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection<\Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum> + */ +class NonDataCollectionWithoutKey extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class CollectionWhoImplementsIterator implements Iterator +{ + public function current(): mixed + { + } + public function next(): void + { + } + public function key(): mixed + { + } + public function valid(): bool + { + return true; + } + public function rewind(): void + { + } +} + +/** + * @extends \Illuminate\Support\Collection + */ +class CollectionWhoImplementsIteratorAggregate implements IteratorAggregate +{ + public function getIterator(): Traversable + { + return $this; + } +} + +/** + * @extends \Illuminate\Support\Collection + */ +class CollectionWhoImplementsNothing +{ +} + +class CollectionWithoutDocBlock extends Collection +{ +} + +/** + * @extends \Illuminate\Support\Collection + */ +class CollectionWithoutType extends Collection +{ +} diff --git a/tests/Support/DataPropertyTypeTest.php b/tests/Support/DataPropertyTypeTest.php index 3dd121d6..402c1046 100644 --- a/tests/Support/DataPropertyTypeTest.php +++ b/tests/Support/DataPropertyTypeTest.php @@ -35,6 +35,7 @@ use Spatie\LaravelData\Support\Types\NamedType; use Spatie\LaravelData\Support\Types\UnionType; use Spatie\LaravelData\Tests\Factories\FakeDataStructureFactory; +use Spatie\LaravelData\Tests\Fakes\Collections\SimpleDataCollectionWithAnotations; use Spatie\LaravelData\Tests\Fakes\ComplicatedData; use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; use Spatie\LaravelData\Tests\Fakes\SimpleData; @@ -572,6 +573,54 @@ function resolveDataType(object $class, string $property = 'property'): DataProp ->iterableClass->toBe(Collection::class); }); +it('can deduce an enumerable data collection type from collection', function () { + $type = resolveDataType(new class () { + public SimpleDataCollectionWithAnotations $property; + }); + + expect($type) + ->isOptional->toBeFalse() + ->isNullable->toBeFalse() + ->isMixed->toBeFalse() + ->lazyType->toBeNull() + ->kind->toBe(DataTypeKind::DataEnumerable) + ->dataClass->toBe(SimpleData::class) + ->iterableClass->toBe(SimpleDataCollectionWithAnotations::class) + ->getAcceptedTypes()->toHaveKeys([SimpleDataCollectionWithAnotations::class]); + + expect($type->type) + ->toBeInstanceOf(NamedType::class) + ->name->toBe(SimpleDataCollectionWithAnotations::class) + ->builtIn->toBeFalse() + ->kind->toBe(DataTypeKind::DataEnumerable) + ->dataClass->toBe(SimpleData::class) + ->iterableClass->toBe(SimpleDataCollectionWithAnotations::class); +}); + +it('can deduce an enumerable data collection union type from collection', function () { + $type = resolveDataType(new class () { + public SimpleDataCollectionWithAnotations|Lazy $property; + }); + + expect($type) + ->isOptional->toBeFalse() + ->isNullable->toBeFalse() + ->isMixed->toBeFalse() + ->lazyType->toBe(Lazy::class) + ->kind->toBe(DataTypeKind::DataEnumerable) + ->dataClass->toBe(SimpleData::class) + ->iterableClass->toBe(SimpleDataCollectionWithAnotations::class) + ->getAcceptedTypes()->toHaveKeys([SimpleDataCollectionWithAnotations::class]); + + expect($type->type) + ->toBeInstanceOf(NamedType::class) + ->name->toBe(SimpleDataCollectionWithAnotations::class) + ->builtIn->toBeFalse() + ->kind->toBe(DataTypeKind::DataEnumerable) + ->dataClass->toBe(SimpleData::class) + ->iterableClass->toBe(SimpleDataCollectionWithAnotations::class); +}); + it('can deduce a paginator data collection type', function () { $type = resolveDataType(new class () { #[DataCollectionOf(SimpleData::class)]