From b933c0c888539322638ddbf57bae2e9d7cc6e11d Mon Sep 17 00:00:00 2001 From: butschster Date: Fri, 5 Jan 2024 11:02:37 +0400 Subject: [PATCH 1/3] Adds EntityCaster for spiral/filters component fixes #75 --- .editorconfig | 3 + composer.json | 13 +++- src/Filter/EntityCaster.php | 67 +++++++++++++++++ tests/app/Bootloader/AppBootloader.php | 7 ++ tests/app/Controller/Filter/RoleFilter.php | 21 ++++++ tests/app/Controller/HomeController.php | 9 ++- tests/app/Controller/RoleController.php | 31 ++++++++ tests/app/Database/Factory/RoleFactory.php | 32 +++++++++ tests/app/Database/Factory/UserFactory.php | 48 +++++++++++++ tests/src/BaseTest.php | 8 +++ tests/src/DatabaseTest.php | 42 +++++++++++ tests/src/Filter/EntityCasterTest.php | 72 +++++++++++++++++++ tests/src/Filter/FilterTest.php | 47 ++++++++++++ .../src/Interceptor/CycleInterceptorTest.php | 44 ++++++------ 14 files changed, 418 insertions(+), 26 deletions(-) create mode 100644 src/Filter/EntityCaster.php create mode 100644 tests/app/Controller/Filter/RoleFilter.php create mode 100644 tests/app/Controller/RoleController.php create mode 100644 tests/app/Database/Factory/RoleFactory.php create mode 100644 tests/app/Database/Factory/UserFactory.php create mode 100644 tests/src/DatabaseTest.php create mode 100644 tests/src/Filter/EntityCasterTest.php create mode 100644 tests/src/Filter/FilterTest.php diff --git a/.editorconfig b/.editorconfig index 9866c39..d2e3f8e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,6 @@ insert_final_newline = true indent_style = space indent_size = 4 trim_trailing_whitespace = true + +[*.json] +indent_size = 2 \ No newline at end of file diff --git a/composer.json b/composer.json index 40f222d..466008b 100644 --- a/composer.json +++ b/composer.json @@ -28,10 +28,17 @@ "cycle/schema-builder": "^2.6", "doctrine/inflector": "^1.4 || ^2.0", "spiral/attributes": "^2.10 || ^3.0", - "spiral/framework": "^3.3", "spiral/reactor": "^3.0", "spiral/scaffolder": "^3.0", "spiral/prototype": "^3.0", + "spiral/console": "^3.0", + "spiral/core": "^3.0", + "spiral/boot": "^3.0", + "spiral/auth": "^3.0", + "spiral/tokenizer": "^3.0", + "spiral/config": "^3.0", + "spiral/validator": "^1.2", + "spiral/filters": "^3.9", "spiral/data-grid-bridge": "^3.0", "psr/container": "^1.1 || ^2.0" }, @@ -41,8 +48,10 @@ "infection/infection": "^0.26.6", "mockery/mockery": "^1.5", "phpunit/phpunit": "^9.5.20", + "spiral/framework": "^3.9", "spiral/testing": "^2.4", - "spiral/validator": "^1.2", + "spiral/nyholm-bridge": "^1.3", + "spiral-packages/database-seeder": "^3.1", "vimeo/psalm": "^4.27" }, "autoload": { diff --git a/src/Filter/EntityCaster.php b/src/Filter/EntityCaster.php new file mode 100644 index 0000000..a4c9af4 --- /dev/null +++ b/src/Filter/EntityCaster.php @@ -0,0 +1,67 @@ + + */ + private static array $cache = []; + private ?ORMInterface $orm = null; + + public function __construct( + protected readonly ContainerInterface $container, + ) { + } + + public function supports(\ReflectionNamedType $type): bool + { + if ($type->isBuiltin()) { + return false; + } + + return $this->getOrm()->getSchema()->defines($type->getName()); + } + + public function setValue(FilterInterface $filter, \ReflectionProperty $property, mixed $value): void + { + $role = $this->resolveRole($property->getType()); + $object = $this->getOrm()->getRepository($role)->findByPK($value); + + if ($object === null && !$property->getType()->allowsNull()) { + throw new SetterException(message: \sprintf('Unable to find entity `%s` by primary key "%s"', $role, $value)); + } + + $property->setValue($filter, $object); + } + + private function resolveRole(\ReflectionNamedType $type): string + { + if (isset(self::$cache[$type->getName()])) { + return self::$cache[$type->getName()]; + } + + $role = $this->getOrm()->resolveRole($type->getName()); + self::$cache[$type->getName()] = $role; + + return $role; + } + + private function getOrm(): ORMInterface + { + if ($this->orm === null) { + $this->orm = $this->container->get(ORMInterface::class); + } + + return $this->orm; + } +} diff --git a/tests/app/Bootloader/AppBootloader.php b/tests/app/Bootloader/AppBootloader.php index dcc4a4c..cb2b963 100644 --- a/tests/app/Bootloader/AppBootloader.php +++ b/tests/app/Bootloader/AppBootloader.php @@ -8,7 +8,9 @@ use Spiral\App\Repositories\RoleRepositoryInterface; use Spiral\Bootloader\DomainBootloader; use Spiral\Core\CoreInterface; +use Spiral\Cycle\Filter\EntityCaster; use Spiral\Cycle\Interceptor\CycleInterceptor; +use Spiral\Filters\Model\Mapper\CasterRegistryInterface; final class AppBootloader extends DomainBootloader { @@ -23,4 +25,9 @@ final class AppBootloader extends DomainBootloader protected const INTERCEPTORS = [ CycleInterceptor::class, ]; + + public function init(CasterRegistryInterface $casterRegistry, EntityCaster $caster): void + { + $casterRegistry->register($caster); + } } diff --git a/tests/app/Controller/Filter/RoleFilter.php b/tests/app/Controller/Filter/RoleFilter.php new file mode 100644 index 0000000..7142ae4 --- /dev/null +++ b/tests/app/Controller/Filter/RoleFilter.php @@ -0,0 +1,21 @@ +getName(); + return [ + 'user' => $user->getName(), + ]; } public function entity2(User $user, Role $role) { - return 'ok'; + return [ + 'user' => $user->getName(), + 'role' => $role->name, + ]; } public function index(): string diff --git a/tests/app/Controller/RoleController.php b/tests/app/Controller/RoleController.php new file mode 100644 index 0000000..f5f10e6 --- /dev/null +++ b/tests/app/Controller/RoleController.php @@ -0,0 +1,31 @@ + $filter->name, + 'role' => $filter->role->name, + 'id' => $filter->role->id, + ]; + } + + #[Route(route: "/role/", methods: ["GET"])] + public function show(Role $role): array + { + return [ + 'name' => $role->name, + 'id' => $role->id, + ]; + } +} diff --git a/tests/app/Database/Factory/RoleFactory.php b/tests/app/Database/Factory/RoleFactory.php new file mode 100644 index 0000000..5a20745 --- /dev/null +++ b/tests/app/Database/Factory/RoleFactory.php @@ -0,0 +1,32 @@ + + */ +final class RoleFactory extends AbstractFactory +{ + public function makeEntity(array $definition): object + { + return new Role($definition['name']); + } + + public function entity(): string + { + return Role::class; + } + + public function definition(): array + { + return [ + 'name' => $this->faker->word, + ]; + } +} diff --git a/tests/app/Database/Factory/UserFactory.php b/tests/app/Database/Factory/UserFactory.php new file mode 100644 index 0000000..93ebcb8 --- /dev/null +++ b/tests/app/Database/Factory/UserFactory.php @@ -0,0 +1,48 @@ + + */ +final class UserFactory extends AbstractFactory +{ + public function makeEntity(array $definition): object + { + $user = new User($definition['name']); + $user->email = $definition['email']; + $user->company = $definition['company']; + + return $user; + } + + public function addRole(Role $role): self + { + return $this->entityState(static function (User $user) use ($role) { + $user->roles->add($role); + + return $user; + }); + } + + public function entity(): string + { + return User::class; + } + + public function definition(): array + { + return [ + 'name' => $this->faker->name, + 'email' => $this->faker->email, + 'company' => $this->faker->company, + ]; + } +} diff --git a/tests/src/BaseTest.php b/tests/src/BaseTest.php index 23c1b43..74c034f 100644 --- a/tests/src/BaseTest.php +++ b/tests/src/BaseTest.php @@ -11,11 +11,13 @@ use Spiral\App\Bootloader\AppBootloader; use Spiral\App\Bootloader\SyncTablesBootloader; use Spiral\Bootloader as Framework; +use Spiral\Nyholm\Bootloader as Nyholm; use Spiral\Config\Patch\Set; use Spiral\Console\Bootloader\ConsoleBootloader; use Spiral\Core\ConfigsInterface; use Spiral\Cycle\Bootloader as CycleBridge; use Spiral\DataGrid\Bootloader\GridBootloader; +use Spiral\Router\Bootloader\AnnotatedRoutesBootloader; use Spiral\Testing\TestCase; abstract class BaseTest extends TestCase @@ -64,6 +66,12 @@ public function defineBootloaders(): array CycleBridge\DatabaseBootloader::class, CycleBridge\MigrationsBootloader::class, + // Http + AnnotatedRoutesBootloader::class, + Framework\Http\RouterBootloader::class, + Nyholm\NyholmBootloader::class, + Framework\Security\FiltersBootloader::class, + // ORM CycleBridge\SchemaBootloader::class, CycleBridge\CycleOrmBootloader::class, diff --git a/tests/src/DatabaseTest.php b/tests/src/DatabaseTest.php new file mode 100644 index 0000000..75ce327 --- /dev/null +++ b/tests/src/DatabaseTest.php @@ -0,0 +1,42 @@ +cleanIdentityMap(); + $this->getCurrentDatabaseDriver()->disconnect(); + } + + public function persist(object ...$entity): void + { + $em = $this->getEntityManager(); + foreach ($entity as $e) { + $em->persist($e); + } + $em->run(); + } + + /** + * @template T of object + * @param T $entity + * @return T + */ + public function refreshEntity(object $entity, string $pkField = 'uuid'): object + { + return $this->getRepositoryFor($entity)->findByPK($entity->{$pkField}); + } +} diff --git a/tests/src/Filter/EntityCasterTest.php b/tests/src/Filter/EntityCasterTest.php new file mode 100644 index 0000000..775cdb0 --- /dev/null +++ b/tests/src/Filter/EntityCasterTest.php @@ -0,0 +1,72 @@ +cleanIdentityMap(); + } + + public function testExistsRole(): void + { + $filter = new \ReflectionClass(RoleFilter::class); + + $caster = new EntityCaster($this->getContainer()); + + $role = RoleFactory::new()->makeOne(); + UserFactory::new()->addRole($role)->createOne(); + + $property = $filter->getProperty('role'); + + $this->assertTrue($caster->supports($property->getType())); + + $caster->setValue($obj = $filter->newInstance(), $property, $role->id); + + $this->assertSame($role->name, $obj->role->name); + $this->assertSame($role->id, $obj->role->id); + } + + public function testNonExistRole(): void + { + $this->expectException(SetterException::class); + $this->expectExceptionMessage('Unable to find entity `role` by primary key "1"'); + + $filter = new \ReflectionClass(RoleFilter::class); + + $caster = new EntityCaster($this->getContainer()); + + $property = $filter->getProperty('role'); + + $this->assertTrue($caster->supports($property->getType())); + + $caster->setValue($obj = $filter->newInstance(), $property, 1); + } + + public function testNonExistNullableRole(): void + { + $filter = new \ReflectionClass(RoleFilter::class); + + $caster = new EntityCaster($this->getContainer()); + + $property = $filter->getProperty('nullableRole'); + + $this->assertTrue($caster->supports($property->getType())); + + $caster->setValue($obj = $filter->newInstance(), $property, 1); + + $this->assertNull($obj->nullableRole); + } +} diff --git a/tests/src/Filter/FilterTest.php b/tests/src/Filter/FilterTest.php new file mode 100644 index 0000000..5a314a0 --- /dev/null +++ b/tests/src/Filter/FilterTest.php @@ -0,0 +1,47 @@ +cleanIdentityMap(); + } + + public function testResolveEntity(): void + { + $role = RoleFactory::new()->makeOne(); + UserFactory::new()->addRole($role)->createOne(); + + $response = $this->fakeHttp()->post('/role', [ + 'role' => $role->id, + 'name' => 'test' + ]); + + $response->assertBodySame(\json_encode([ + 'name' => 'test', + 'role' => $role->name, + 'id' => $role->id, + ])); + } + + public function testResolveNonExistsEntity(): void + { + $this->expectException(ValidationException::class); + + $this->fakeHttp()->post('/role', [ + 'role' => 2, + 'name' => 'test' + ]); + } +} diff --git a/tests/src/Interceptor/CycleInterceptorTest.php b/tests/src/Interceptor/CycleInterceptorTest.php index 67ebea1..aa8c2de 100644 --- a/tests/src/Interceptor/CycleInterceptorTest.php +++ b/tests/src/Interceptor/CycleInterceptorTest.php @@ -4,29 +4,29 @@ namespace Spiral\Tests\Interceptor; -use Cycle\ORM\EntityManager; -use Spiral\App\Entities\Role; +use Spiral\App\Database\Factory\RoleFactory; +use Spiral\App\Database\Factory\UserFactory; use Spiral\Core\CoreInterface; use Spiral\Core\Exception\ControllerException; use Spiral\App\Controller\HomeController; use Spiral\App\Entities\User; -use Spiral\Tests\ConsoleTest; +use Spiral\Tests\DatabaseTest; -final class CycleInterceptorTest extends ConsoleTest +final class CycleInterceptorTest extends DatabaseTest { private User $contextEntity; public function setUp(): void { parent::setUp(); - $this->runCommand('cycle:sync'); + $this->cleanIdentityMap(); - $u = new User('Antony'); - $u->roles->add(new Role('admin')); - $this->contextEntity = new User('Contextual'); - $this->contextEntity->roles->add(new Role('user')); + $role = RoleFactory::new(['name' => 'admin'])->makeOne(); + UserFactory::new(['name' => 'Antony'])->addRole($role)->createOne(); - $this->getContainer()->get(EntityManager::class)->persist($u)->persist($this->contextEntity)->run(); + $this->contextEntity = UserFactory::new(['name' => 'Contextual']) + ->addRole(RoleFactory::new()->makeOne()) + ->createOne(); } public function testCallBadAction(): void @@ -74,8 +74,8 @@ public function testInjectedInstance2(): void $core = $this->getContainer()->get(CoreInterface::class); $this->assertSame( - 'Antony', - $core->callAction(HomeController::class, 'entity', ['user' => 1]) + ['user' => 'Antony'], + $core->callAction(HomeController::class, 'entity', ['user' => 1]), ); } @@ -87,8 +87,8 @@ public function testInjectedTheSameTwice(): void /** @var CoreInterface $core */ $core = $this->getContainer()->get(CoreInterface::class); - $this->assertSame('Antony', $core->callAction(HomeController::class, 'entity', ['user' => 1])); - $this->assertSame('Antony', $core->callAction(HomeController::class, 'entity', ['user' => 1])); + $this->assertSame(['user' => 'Antony'], $core->callAction(HomeController::class, 'entity', ['user' => 1])); + $this->assertSame(['user' => 'Antony'], $core->callAction(HomeController::class, 'entity', ['user' => 1])); } // singular entity @@ -98,8 +98,8 @@ public function testInjectedInstance3(): void $core = $this->getContainer()->get(CoreInterface::class); $this->assertSame( - 'Antony', - $core->callAction(HomeController::class, 'entity', ['id' => 1]) + ['user' => 'Antony'], + $core->callAction(HomeController::class, 'entity', ['id' => 1]), ); } @@ -119,8 +119,8 @@ public function testMultipleEntities(): void $core = $this->getContainer()->get(CoreInterface::class); $this->assertSame( - 'ok', - $core->callAction(HomeController::class, 'entity2', ['user' => 1, 'role' => 1]) + ['user' => 'Antony', 'role' => 'admin'], + $core->callAction(HomeController::class, 'entity2', ['user' => 1, 'role' => 1]), ); } @@ -133,8 +133,8 @@ public function testBypass(): void $core = $this->getContainer()->get(CoreInterface::class); $this->assertSame( - 'Demo', - $core->callAction(HomeController::class, 'entity', ['user' => new User('Demo')]) + ['user' => 'Demo'], + $core->callAction(HomeController::class, 'entity', ['user' => new User('Demo')]), ); } @@ -147,8 +147,8 @@ public function testInjectedTWithContext(): void $core = $this->getContainer()->get(CoreInterface::class); $this->assertSame( - 'Contextual', - $core->callAction(HomeController::class, 'entity', ['user' => $this->contextEntity]) + ['user' => 'Contextual'], + $core->callAction(HomeController::class, 'entity', ['user' => $this->contextEntity]), ); } } From f0b36a7c5702b498548df2558e69e94d8c56e14b Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Jan 2024 14:34:54 +0400 Subject: [PATCH 2/3] Fixes unit tests --- composer.json | 4 ++-- tests/app/Entities/Role.php | 5 ++++- tests/app/Entities/User.php | 7 +++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 466008b..a80fbf6 100644 --- a/composer.json +++ b/composer.json @@ -38,12 +38,12 @@ "spiral/tokenizer": "^3.0", "spiral/config": "^3.0", "spiral/validator": "^1.2", - "spiral/filters": "^3.9", + "spiral/filters": "^3.10", "spiral/data-grid-bridge": "^3.0", "psr/container": "^1.1 || ^2.0" }, "require-dev": { - "doctrine/collections": "^1.6", + "doctrine/collections": "^2.0", "illuminate/collections": "^9.0", "infection/infection": "^0.26.6", "mockery/mockery": "^1.5", diff --git a/tests/app/Entities/Role.php b/tests/app/Entities/Role.php index 72b956a..94c37d8 100644 --- a/tests/app/Entities/Role.php +++ b/tests/app/Entities/Role.php @@ -8,7 +8,10 @@ use Cycle\Annotated\Annotation\Entity; use Spiral\App\Repositories\RoleRepository; -#[Entity(repository: RoleRepository::class)] +#[ + Entity(repository: RoleRepository::class), + \AllowDynamicProperties +] class Role { #[Column(type: 'primary')] diff --git a/tests/app/Entities/User.php b/tests/app/Entities/User.php index 8487222..1941d43 100644 --- a/tests/app/Entities/User.php +++ b/tests/app/Entities/User.php @@ -11,7 +11,10 @@ use Doctrine\Common\Collections\ArrayCollection; use Spiral\App\Repositories\UserRepository; -#[Entity(repository: UserRepository::class)] +#[ + Entity(repository: UserRepository::class), + \AllowDynamicProperties +] class User { #[Column(type: 'primary')] @@ -46,7 +49,7 @@ class User public function __construct( #[Column(type: 'string')] - private string $name + private string $name, ) { $this->friendsAsDoctrineCollection = new ArrayCollection(); $this->roles = new ArrayCollection(); From c077539937152738798078337143d461491ff93e Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 8 Jan 2024 14:38:34 +0400 Subject: [PATCH 3/3] Add error suppression for cases when invalid data is used for entity retrieval. --- src/Filter/EntityCaster.php | 15 ++++++++++++--- tests/src/Filter/EntityCasterTest.php | 16 +++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/Filter/EntityCaster.php b/src/Filter/EntityCaster.php index a4c9af4..c592dfd 100644 --- a/src/Filter/EntityCaster.php +++ b/src/Filter/EntityCaster.php @@ -6,6 +6,7 @@ use Cycle\ORM\ORMInterface; use Psr\Container\ContainerInterface; +use Spiral\Exceptions\ExceptionReporterInterface; use Spiral\Filters\Exception\SetterException; use Spiral\Filters\Model\FilterInterface; use Spiral\Filters\Model\Mapper\CasterInterface; @@ -20,6 +21,7 @@ final class EntityCaster implements CasterInterface public function __construct( protected readonly ContainerInterface $container, + protected readonly ExceptionReporterInterface $reporter, ) { } @@ -34,11 +36,18 @@ public function supports(\ReflectionNamedType $type): bool public function setValue(FilterInterface $filter, \ReflectionProperty $property, mixed $value): void { - $role = $this->resolveRole($property->getType()); - $object = $this->getOrm()->getRepository($role)->findByPK($value); + try { + $role = $this->resolveRole($property->getType()); + $object = $this->getOrm()->getRepository($role)->findByPK($value); + } catch (\Throwable $e) { + $this->reporter->report($e); + throw new SetterException(previous: $e); + } if ($object === null && !$property->getType()->allowsNull()) { - throw new SetterException(message: \sprintf('Unable to find entity `%s` by primary key "%s"', $role, $value)); + throw new SetterException( + message: \sprintf('Unable to find entity `%s` by primary key "%s"', $role, $value), + ); } $property->setValue($filter, $object); diff --git a/tests/src/Filter/EntityCasterTest.php b/tests/src/Filter/EntityCasterTest.php index 775cdb0..75f00c7 100644 --- a/tests/src/Filter/EntityCasterTest.php +++ b/tests/src/Filter/EntityCasterTest.php @@ -8,6 +8,7 @@ use Spiral\App\Database\Factory\RoleFactory; use Spiral\App\Database\Factory\UserFactory; use Spiral\Cycle\Filter\EntityCaster; +use Spiral\Exceptions\ExceptionReporterInterface; use Spiral\Filters\Exception\SetterException; use Spiral\Tests\DatabaseTest; @@ -24,7 +25,10 @@ public function testExistsRole(): void { $filter = new \ReflectionClass(RoleFilter::class); - $caster = new EntityCaster($this->getContainer()); + $caster = new EntityCaster( + $this->getContainer(), + $this->getContainer()->get(ExceptionReporterInterface::class) + ); $role = RoleFactory::new()->makeOne(); UserFactory::new()->addRole($role)->createOne(); @@ -46,7 +50,10 @@ public function testNonExistRole(): void $filter = new \ReflectionClass(RoleFilter::class); - $caster = new EntityCaster($this->getContainer()); + $caster = new EntityCaster( + $this->getContainer(), + $this->getContainer()->get(ExceptionReporterInterface::class) + ); $property = $filter->getProperty('role'); @@ -59,7 +66,10 @@ public function testNonExistNullableRole(): void { $filter = new \ReflectionClass(RoleFilter::class); - $caster = new EntityCaster($this->getContainer()); + $caster = new EntityCaster( + $this->getContainer(), + $this->getContainer()->get(ExceptionReporterInterface::class) + ); $property = $filter->getProperty('nullableRole');