diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ead574 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/README.md export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5b93f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +/composer.lock +###> phpunit/phpunit ### +/phpunit.xml +/build +/.phpunit.result.cache +/tests/app/cache diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f7bd39a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: php +php: + - 7.2 + +install: + - travis_retry composer self-update + - travis_retry composer update ${COMPOSER_FLAGS} --no-interaction + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction + +script: + - mkdir -p build/logs + - php vendor/bin/phpunit -c phpunit.xml.dist + +after_success: + - wget https://github.com/satooshi/php-coveralls/releases/download/v1.0.1/coveralls.phar --output-document="${HOME}/bin/coveralls" + - chmod u+x "${HOME}/bin/coveralls" + - coveralls -v diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..23c875a --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "pfilsx/form-layer-bundle", + "description": "Provides additional functional to follow rule \"An entity should be always valid\" with forms validation for your Symfony project", + "type": "symfony-bundle", + "keywords": [ + "form", + "symfony", + "symfony4", + "symfony5", + "bundle", + "entity", + "orm", + "doctrine" + ], + "license": "MIT", + "authors": [ + { + "name": "Pavel Filimonov", + "email": "pfilsx@gmail.com" + } + ], + "minimum-stability": "stable", + "require": { + "php": ">=7.1", + "symfony/framework-bundle": "^3.4|^4.0|^5.0", + "symfony/dependency-injection": "^3.4|^4.0|^5.0", + "symfony/console": "^3.4|^4.0|^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.0", + "phpunit/php-code-coverage": "^7.0", + "symfony/maker-bundle": "*", + "symfony/phpunit-bridge": "^5.0", + "symfony/process": "^3.4|^4.0|^5.0", + "symfony/validator": "^3.4|^4.0|^5.0", + "symfony/yaml": "^3.4|^4.0|^5.0", + "symfony/routing": "^3.4|^4.0|^5.0", + "symfony/orm-pack": "*" + }, + "autoload": { + "psr-4": { + "Pfilsx\\FormLayer\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Pfilsx\\FormLayer\\Tests\\": "tests" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..778d8c2 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + ./tests + + + + + + ./src + + ./src + ./src + ./src/DependencyInjection + + + + + + + + diff --git a/src/DependencyInjection/FormLayerExtension.php b/src/DependencyInjection/FormLayerExtension.php new file mode 100644 index 0000000..0f746e0 --- /dev/null +++ b/src/DependencyInjection/FormLayerExtension.php @@ -0,0 +1,18 @@ +load('services.xml'); + } +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..bba3270 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,11 @@ +registerForAutoconfiguration(FormLayerInterface::class)->addTag('form_layer.type'); + } +} diff --git a/src/Layer/EntityFormLayer.php b/src/Layer/EntityFormLayer.php new file mode 100644 index 0000000..3e1dbe8 --- /dev/null +++ b/src/Layer/EntityFormLayer.php @@ -0,0 +1,108 @@ +entity !== null) { + if (method_exists($this->entity, 'getId')) { + return $this->entity->getId(); + } + if (property_exists($this->entity, 'id')) { + return $this->entity->id; + } + } + return null; + } + + /** + * @inheritDoc + */ + public function load($entity) + { + if (!is_a($entity, static::getEntityClass())) { + throw new InvalidArgumentException('Expected instance of ' . static::getEntityClass() . ', got ' . get_class($entity)); + } + $this->entity = $entity; + $this->loadLayerFields(); + } + + /** + * @inheritDoc + */ + public function create(bool $force = false) + { + if ($force || $this->entity === null) { + $className = static::getEntityClass(); + $this->entity = new $className(); + } + $this->update(); + return $this->entity; + } + + /** + * @inheritDoc + */ + public function update() + { + if ($this->entity !== null) { + $this->loadEntityFields(); + } + } + + /** + * Loads data from associated entity + */ + protected function loadLayerFields() + { + foreach (get_object_vars($this) as $prop => $val) { + $getter = 'get' . $prop; + $value = $val; + if (method_exists($this->entity, $getter)) { + $value = $this->entity->$getter(); + } elseif (property_exists($this->entity, $prop)) { + $value = $this->entity->$prop; + } + $loadMethod = 'load' . $prop; + if (method_exists($this, $loadMethod)) { + $this->$loadMethod($value); + } else { + $this->$prop = $value; + } + } + } + + /** + * Saves data into associated entity + */ + protected function loadEntityFields() + { + foreach (get_object_vars($this) as $prop => $value) { + $saveMethod = 'save' . $prop; + $value = method_exists($this, $saveMethod) ? $this->$saveMethod() : $this->$prop; + $setter = 'set' . $prop; + if (method_exists($this->entity, $setter)) { + $this->entity->$setter($value); + } elseif (property_exists($this->entity, $prop)) { + $this->entity->$prop = $value; + } + } + } +} diff --git a/src/Layer/FormLayerInterface.php b/src/Layer/FormLayerInterface.php new file mode 100644 index 0000000..1fac799 --- /dev/null +++ b/src/Layer/FormLayerInterface.php @@ -0,0 +1,27 @@ +entityHelper = $entityHelper; + $this->formLayerRenderer = $formLayerRenderer; + } + + public static function getCommandName(): string + { + return 'make:form-layer'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig) + { + $command + ->setDescription('Creates a new form layer class') + ->addArgument('name', InputArgument::OPTIONAL, sprintf('The name of the form layer class (e.g. %sFormLayer)', Str::asClassName(Str::getRandomTerm()))) + ->addArgument('bound-class', InputArgument::OPTIONAL, 'The name of Entity or fully qualified model class name that the new form will be bound to (empty for none)'); + $inputConfig->setArgumentAsNonInteractive('bound-class'); + } + + public function configureDependencies(DependencyBuilder $dependencies) + { + $dependencies->addClassDependency( + DoctrineBundle::class, + 'orm' + ); + } + + /** + * @param InputInterface $input + * @param ConsoleStyle $io + * @param Command $command + * + * @codeCoverageIgnore + */ + public function interact(InputInterface $input, ConsoleStyle $io, Command $command) + { + if (null === $input->getArgument('bound-class')) { + $argument = $command->getDefinition()->getArgument('bound-class'); + $entities = $this->entityHelper->getEntitiesForAutocomplete(); + $question = new Question($argument->getDescription()); + $question->setValidator(function ($answer) use ($entities) { + return Validator::existsOrNull($answer, $entities); + }); + $question->setAutocompleterValues($entities); + $question->setMaxAttempts(3); + $input->setArgument('bound-class', $io->askQuestion($question)); + } + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) + { + $formClassNameDetails = $generator->createClassNameDetails( + $input->getArgument('name'), + 'FormLayer\\', + 'FormLayer' + ); + + $formFields = ['form_field']; + + $boundClass = $input->getArgument('bound-class'); + $boundClassDetails = null; + if (null !== $boundClass) { + $formFields = []; + $boundClassDetails = $generator->createClassNameDetails( + $boundClass, + 'Entity\\' + ); + $doctrineMetadata = $this->entityHelper->getMetadata($boundClassDetails->getFullName()); + if ($doctrineMetadata instanceof ClassMetadata) { + foreach ($doctrineMetadata->getFieldNames() as $fieldName) { + $formFields[] = $fieldName; + } + foreach ($doctrineMetadata->associationMappings as $fieldName => $relation) { + if ($relation['type'] === ClassMetadata::MANY_TO_ONE) { + $formFields[] = $fieldName; + } + } + } else { + $reflect = new ReflectionClass($boundClassDetails->getFullName()); + foreach ($reflect->getProperties() as $prop) { + $formFields[] = $prop->getName(); + } + } + } + + $this->formLayerRenderer->render( + $formClassNameDetails, + $formFields, + $boundClassDetails + ); + $generator->writeChanges(); + $this->writeSuccessMessage($io); + $io->text('Next: Add fields to your form layer and start using it.'); + } +} diff --git a/src/Renderer/FormLayerRenderer.php b/src/Renderer/FormLayerRenderer.php new file mode 100644 index 0000000..e3c9d76 --- /dev/null +++ b/src/Renderer/FormLayerRenderer.php @@ -0,0 +1,34 @@ +generator = $generator; + } + + public function render(ClassNameDetails $formClassDetails, array $formFields, ClassNameDetails $boundClassDetails = null) + { + $this->generator->generateClass( + $formClassDetails->getFullName(), + __DIR__ . '/../Resources/skeleton/FormLayer.tpl.php', + [ + 'bounded_full_class_name' => $boundClassDetails ? $boundClassDetails->getFullName() : null, + 'bounded_class_name' => $boundClassDetails ? $boundClassDetails->getShortName() : null, + 'form_fields' => $formFields + ] + ); + } +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml new file mode 100644 index 0000000..7fb61e1 --- /dev/null +++ b/src/Resources/config/services.xml @@ -0,0 +1,22 @@ + + + + + Pfilsx\FormLayer\Renderer\FormLayerRenderer + Pfilsx\FormLayer\Maker\MakeFormLayer + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Resources/doc/index.rst b/src/Resources/doc/index.rst new file mode 100644 index 0000000..bce10eb --- /dev/null +++ b/src/Resources/doc/index.rst @@ -0,0 +1,35 @@ +Getting started with FormLayerBundle +======================================== + +Overview +-------- + +The bundle integrates special layer between your doctrine entities and forms for `Symfony`_ project. It +automatically registers all FormLayer classes as services, so you can wire it. + +Here, an example how to use FormLayer: + +.. code-block:: php + + use App\FormLayer\CustomFormLayer; + + ... + + public function update(CustomFormLayer $layer, CustomEntity $entity): Response + { + $layer->load($entity); + $form = $this->createForm(CustomType::class, $layer); + // ... your code with form and if form is submitted and valid + $layer->update(); // updates props in associated entity + $this->getDoctrine()->getManager()->flush(); // for example + return $this->render('entity/index.html.twig', [ + 'form' => $form->createView() + ]); + } + +.. toctree:: + + installation + usage + +.. _`Symfony`: http://symfony.com/ diff --git a/src/Resources/doc/installation.rst b/src/Resources/doc/installation.rst new file mode 100644 index 0000000..dab3f3e --- /dev/null +++ b/src/Resources/doc/installation.rst @@ -0,0 +1,45 @@ +Installation +============ + +Step 1: Download the Bundle +--------------------------- + +Open a command console, enter your project directory and execute the following +command to download the latest stable version of this bundle: + +.. code-block:: bash + + $ composer require pfilsx/form-layer-bundle + +This command requires you to have Composer installed globally, as explained +in the `installation chapter`_ of the Composer documentation. + +Step 2: Enable the Bundle +------------------------- + +Then, enable the bundle by adding the following line in the ``app/AppKernel.php`` +file of your project: + +.. code-block:: php + + field2; // by get... method you can customize how layer should send data to form + } + + public function setField2($value) + { + $this->field2 = $value; // by set... method you can customize how layer should take data from form + return $this; + } + + public function loadField2(\DateTime $value) + { + $this->field2 = $value->format('d.m.Y'); // by load... method you can customize how layer should take data from entity + } + + public function saveField2() + { + return new \DateTime($this->field2); // by save... method you can customize how layer should send data to entity + } + } + +Step 3: Use your layer in your controller +---------------------------------------- + +.. code-block:: php + + public function update(CustomFormLayer $layer, CustomEntity $entity): Response + { + $layer->load($entity); + $form = $this->createForm(CustomType::class, $layer); + // ... your code with form and if form is submitted and valid + $layer->update(); // updates props in associated entity + $this->getDoctrine()->getManager()->flush(); // for example + return $this->render('entity/index.html.twig', [ + 'form' => $form->createView() + ]); + } diff --git a/src/Resources/skeleton/FormLayer.tpl.php b/src/Resources/skeleton/FormLayer.tpl.php new file mode 100644 index 0000000..7ec7e8e --- /dev/null +++ b/src/Resources/skeleton/FormLayer.tpl.php @@ -0,0 +1,28 @@ + + +namespace ; + + +use Symfony\Component\Validator\Constraints as Assert; + +use Pfilsx\FormLayer\Layer\EntityFormLayer; + + +use ; + +/** +* @method create(bool $force = false) +* @method void load( $entity) +*/ + +class extends EntityFormLayer +{ + + public $; + + + public static function getEntityClass(): string + { + return ; + } +} diff --git a/tests/FormLayerBundleTest.php b/tests/FormLayerBundleTest.php new file mode 100644 index 0000000..e95b720 --- /dev/null +++ b/tests/FormLayerBundleTest.php @@ -0,0 +1,20 @@ +build($container); + $this->assertEquals('FormLayerBundle', $bundle->getName()); + } +} diff --git a/tests/KernelTestCase.php b/tests/KernelTestCase.php new file mode 100644 index 0000000..db1478d --- /dev/null +++ b/tests/KernelTestCase.php @@ -0,0 +1,32 @@ +application = new Application($kernel); + $this->application->setAutoExit(false); + $this->application->run(new ArrayInput(array( + 'doctrine:schema:drop', + '--force' => true + ))); + $this->application->run(new ArrayInput(array( + 'doctrine:schema:create' + ))); + } +} diff --git a/tests/Layer/EntityFormLayerTest.php b/tests/Layer/EntityFormLayerTest.php new file mode 100644 index 0000000..45e6dc2 --- /dev/null +++ b/tests/Layer/EntityFormLayerTest.php @@ -0,0 +1,121 @@ +assertNull($layer->getId()); + $this->assertNull($layer->createdAt); + $this->assertNull($layer->content); + + $layer->createdAt = '01.01.1970'; + $node = $layer->create(); + $this->assertInstanceOf($entityClass, $node); + $this->assertEquals(new DateTime('01.01.1970'), $useMethod ? $node->getCreatedAt() : $node->createdAt); + } + + /** + * @dataProvider getLayers + * @param string $formLayerClass + */ + public function testWrongLoad($formLayerClass) + { + $node = new class { + }; + /** + * @var NodeFormLayer|ModelFormLayer $layer + */ + $layer = new $formLayerClass(); + $this->expectException(InvalidArgumentException::class); + $layer->load($node); + } + + /** + * @dataProvider getLayers + * @param string $formLayerClass + * @param string $entityClass + */ + public function testLoad($formLayerClass, $entityClass, $useMethod) + { + /** + * @var Node|Model $node + */ + $node = new $entityClass(); + if ($useMethod) { + $node->setCreatedAt(new DateTime('01.01.1970')) + ->setId(1)->setContent('Test content'); + } else { + $node->createdAt = new DateTime('01.01.1970'); + $node->id = 1; + $node->content = 'Test content'; + } + /** + * @var NodeFormLayer|ModelFormLayer $layer + */ + $layer = new $formLayerClass(); + $layer->load($node); + $this->assertEquals(1, $layer->getId()); + $this->assertEquals('01.01.1970', $layer->createdAt); + $this->assertEquals('Test content', $layer->content); + + $layer->createdAt = '02.01.1970'; + $layer->update(); + $this->assertEquals(new DateTime('02.01.1970'), $useMethod ? $node->getCreatedAt() : $node->createdAt); + } + + /** + * @dataProvider getLayers + * @param string $formLayerClass + * @param string $entityClass + */ + public function testForceCreate($formLayerClass, $entityClass) + { + /** + * @var Node|Model $node + */ + $node = new $entityClass(); + /** + * @var NodeFormLayer|ModelFormLayer $layer + */ + $layer = new $formLayerClass(); + $layer->load($node); + $this->assertSame($node, $layer->create()); + $this->assertNotSame($node, $layer->create(true)); + } + + public function getLayers() + { + yield [ + NodeFormLayer::class, + Node::class, + true + ]; + yield [ + ModelFormLayer::class, + Model::class, + false + ]; + } +} diff --git a/tests/Maker/FunctionalTest.php b/tests/Maker/FunctionalTest.php new file mode 100644 index 0000000..7deb868 --- /dev/null +++ b/tests/Maker/FunctionalTest.php @@ -0,0 +1,88 @@ +app_path = dirname(__DIR__) . '/app'; + parent::setUp(); + } + + public function testWiring() + { + $class = MakeFormLayer::class; + $commandName = $class::getCommandName(); + $this->assertEquals('make:form-layer', $commandName); + $command = $this->application->find($commandName); + $this->assertInstanceOf(MakerCommand::class, $command); + } + + /** + * @dataProvider getCommands + * @param $name + * @param $entity + * @param $result + */ + public function testMaker($name, $entity, $result) + { + $input = new StringInput("make:form-layer $name $entity"); + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, true); + $this->application->run($input, $output); + $filePath = $this->app_path . "/FormLayer/$name.php"; + $this->assertTrue(is_file($filePath)); + $layerClass = "Pfilsx\\FormLayer\\Tests\\app\\FormLayer\\$name"; + $layer = new $layerClass(); + $this->assertInstanceOf(EntityFormLayer::class, $layer); + $this->assertEquals($result, get_object_vars($layer)); + @unlink($filePath); + } + + public function getCommands() + { + yield [ + 'FooBarTestFormLayer', + null, + ['form_field' => null] + ]; + yield [ + 'NodeTestFormLayer', + 'Node', + [ + 'id' => null, + 'content' => null, + 'user' => null, + 'parentId' => null, + 'createdAt' => null, + 'mainNode' => null + ] + ]; + yield [ + 'ModelTestFormLayer', + 'Model', + [ + 'id' => null, + 'content' => null, + 'createdAt' => null + ] + ]; + } +} diff --git a/tests/app/AppKernel.php b/tests/app/AppKernel.php new file mode 100644 index 0000000..7290ee0 --- /dev/null +++ b/tests/app/AppKernel.php @@ -0,0 +1,44 @@ +load(__DIR__ . '/config/config_' . $this->getEnvironment() . '.yml'); + } + + public function getCacheDir() + { + return __DIR__ . '/cache/' . $this->environment; + } +} diff --git a/tests/app/Entity/Model.php b/tests/app/Entity/Model.php new file mode 100644 index 0000000..38ff8f1 --- /dev/null +++ b/tests/app/Entity/Model.php @@ -0,0 +1,14 @@ +content = $content; + return $this; + } + + public function getContent() + { + return $this->content; + } + + public function setCreatedAt($createdAt) + { + $this->createdAt = $createdAt; + return $this; + } + + public function getCreatedAt() + { + return $this->createdAt; + } + + public function setId($id) + { + $this->id = $id; + return $this; + } + + public function getId() + { + return $this->id; + } + + public function setParentId($parentId) + { + $this->parentId = $parentId; + return $this; + } + + public function getParentId() + { + return $this->parentId; + } + + public function setUser($user) + { + $this->user = $user; + return $this; + } + + public function getUser() + { + return $this->user; + } + + public function setMainNode($mainNode) + { + $this->mainNode = $mainNode; + return $this; + } + + public function getMainNode() + { + return $this->mainNode; + } + + public function setSubNodeList($subNodeList) + { + $this->subNodeList = $subNodeList; + return $this; + } + + public function getSubNodeList() + { + return $this->subNodeList; + } +} diff --git a/tests/app/FormLayer/ModelFormLayer.php b/tests/app/FormLayer/ModelFormLayer.php new file mode 100644 index 0000000..4eca08b --- /dev/null +++ b/tests/app/FormLayer/ModelFormLayer.php @@ -0,0 +1,30 @@ +createdAt = $val !== null ? $val->format('d.m.Y') : $val; + } + + protected function saveCreatedAt() + { + return new DateTime($this->createdAt); + } +} diff --git a/tests/app/FormLayer/NodeFormLayer.php b/tests/app/FormLayer/NodeFormLayer.php new file mode 100644 index 0000000..ccb6f14 --- /dev/null +++ b/tests/app/FormLayer/NodeFormLayer.php @@ -0,0 +1,30 @@ +createdAt = $val !== null ? $val->format('d.m.Y') : $val; + } + + protected function saveCreatedAt() + { + return new DateTime($this->createdAt); + } +} diff --git a/tests/app/Resources/config/doctrine/Node.orm.xml b/tests/app/Resources/config/doctrine/Node.orm.xml new file mode 100644 index 0000000..8f9eedb --- /dev/null +++ b/tests/app/Resources/config/doctrine/Node.orm.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/app/config/config_test.yml b/tests/app/config/config_test.yml new file mode 100644 index 0000000..0e13933 --- /dev/null +++ b/tests/app/config/config_test.yml @@ -0,0 +1,21 @@ +framework: + trusted_hosts: ~ + secret: "test" + csrf_protection: false + test: ~ + router: + resource: "%kernel.project_dir%/tests/app/config/routing.yml" +maker: + root_namespace: Pfilsx\FormLayer\Tests\app +doctrine: + dbal: + driver: 'pdo_sqlite' + memory: true + orm: + entity_managers: + default: + mappings: + Pfilsx\FormLayer\Tests\app\Entity\Node: + type: xml + dir: "%kernel.project_dir%/tests/app/Resources/config/doctrine" + prefix: Pfilsx\FormLayer\Tests\app\Entity diff --git a/tests/app/config/routing.yml b/tests/app/config/routing.yml new file mode 100644 index 0000000..01f3202 --- /dev/null +++ b/tests/app/config/routing.yml @@ -0,0 +1,6 @@ +test_prefix_show: + path: '/{id}/show' +test_prefix_edit: + path: '/{id}/edit' +test_prefix_delete: + path: '/{id}/delete'