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]), ); } }