generated from spatie/package-skeleton-laravel
-
-
Notifications
You must be signed in to change notification settings - Fork 207
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #812 from clementbirkle/detect-data-from-collection
Detect data from collection
- Loading branch information
Showing
13 changed files
with
738 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
) { | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); | ||
}); |
Oops, something went wrong.