Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect data from collection #812

Merged
merged 17 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/as-a-data-transfer-object/nesting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TKey, TData>
*/
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
Expand Down
3 changes: 3 additions & 0 deletions src/LaravelDataServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,6 +44,8 @@ function () {
}
);

$this->app->singleton(ContextResolver::class);

$this->app->beforeResolving(BaseData::class, function ($class, $parameters, $app) {
if ($app->has($class)) {
return;
Expand Down
24 changes: 24 additions & 0 deletions src/Resolvers/ContextResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Spatie\LaravelData\Resolvers;

use phpDocumentor\Reflection\Types\Context;
use phpDocumentor\Reflection\Types\ContextFactory;
use ReflectionClass;
use ReflectionMethod;
use ReflectionProperty;

class ContextResolver
{
/** @var array<string, Context> */
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);
}
}
13 changes: 13 additions & 0 deletions src/Support/Annotations/CollectionAnnotation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Spatie\LaravelData\Support\Annotations;

class CollectionAnnotation
{
public function __construct(
public string $type,
public bool $isData,
public string $keyType = 'array-key',
) {
}
}
170 changes: 170 additions & 0 deletions src/Support/Annotations/CollectionAnnotationReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

namespace Spatie\LaravelData\Support\Annotations;

use Iterator;
use IteratorAggregate;
use phpDocumentor\Reflection\DocBlock\Tags\Generic;
use phpDocumentor\Reflection\DocBlockFactory;
use phpDocumentor\Reflection\TypeResolver;
use phpDocumentor\Reflection\Types\Context;
use ReflectionClass;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Resolvers\ContextResolver;

class CollectionAnnotationReader
{
public function __construct(
protected readonly ContextResolver $contextResolver,
protected readonly TypeResolver $typeResolver,
) {
}

/** @var array<class-string, CollectionAnnotation|null> */
protected static array $cache = [];

protected Context $context;
clementbirkle marked this conversation as resolved.
Show resolved Hide resolved

/**
* @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;
}
}
20 changes: 6 additions & 14 deletions src/Support/Annotations/DataIterableAnnotationReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Context> */
protected static array $contexts = [];
public function __construct(
protected readonly ContextResolver $contextResolver,
) {
}

/** @return array<string, DataIterableAnnotation> */
public function getForClass(ReflectionClass $class): array
Expand Down Expand Up @@ -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);
}
}
13 changes: 13 additions & 0 deletions src/Support/Factories/DataTypeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,6 +37,7 @@ class DataTypeFactory
{
public function __construct(
protected DataIterableAnnotationReader $iterableAnnotationReader,
protected CollectionAnnotationReader $collectionAnnotationReader,
) {
}

Expand Down Expand Up @@ -354,6 +356,17 @@ protected function inferPropertiesForNamedType(
$iterableKeyType = $annotation->keyType;
}

if (
clementbirkle marked this conversation as resolved.
Show resolved Hide resolved
$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;
Expand Down
49 changes: 49 additions & 0 deletions tests/CollectionAttributeWithAnotationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

use Spatie\LaravelData\Tests\Fakes\Collections\SimpleDataCollectionWithAnotations;
use Spatie\LaravelData\Tests\Fakes\DataWithSimpleDataCollectionWithAnotations;
use Spatie\LaravelData\Tests\Fakes\SimpleData;
use Spatie\LaravelData\Tests\TestSupport\DataValidationAsserter;

beforeEach(function () {
$this->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
);
});
Loading
Loading