Skip to content

Commit

Permalink
Merge pull request #812 from clementbirkle/detect-data-from-collection
Browse files Browse the repository at this point in the history
Detect data from collection
  • Loading branch information
rubenvanassche authored Aug 13, 2024
2 parents c5bb5d8 + 9fe2d22 commit 9d7edfa
Show file tree
Hide file tree
Showing 13 changed files with 738 additions and 14 deletions.
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;

/**
* @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 (
$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

0 comments on commit 9d7edfa

Please sign in to comment.