diff --git a/src/Command/MakerCommand.php b/src/Command/MakerCommand.php index c7c1ed404..83722b94c 100644 --- a/src/Command/MakerCommand.php +++ b/src/Command/MakerCommand.php @@ -65,7 +65,7 @@ protected function initialize(InputInterface $input, OutputInterface $output) $this->maker->configureDependencies($dependencies, $input); if (!$dependencies->isPhpVersionSatisfied()) { - throw new RuntimeCommandException('The make:entity command requires that you use PHP 7.1 or higher.'); + throw new RuntimeCommandException(sprintf('The %s command requires that you use PHP 7.1 or higher.', $this->maker->getCommandName())); } if ($missingPackagesMessage = $dependencies->getMissingPackagesMessage($this->getName())) { diff --git a/src/Maker/MakeDto.php b/src/Maker/MakeDto.php new file mode 100644 index 000000000..cf9b4a3d8 --- /dev/null +++ b/src/Maker/MakeDto.php @@ -0,0 +1,464 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker; + +use Doctrine\Common\Annotations\AnnotationReader; +use Doctrine\ORM\Mapping\ClassMetadata; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\FileManager; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Str; +use Symfony\Bundle\MakerBundle\Util\ClassDetails; +use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; +use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator; +use Symfony\Bundle\MakerBundle\Util\DTOClassSourceManipulator; +use Symfony\Bundle\MakerBundle\Validator; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Validation; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * @author Clemens Krack + */ +final class MakeDto extends AbstractMaker +{ + private $doctrineHelper; + private $fileManager; + private $validator; + private $validatorClassMetadata; + + private const DTO_STYLES = [ + 1 => 'Mutable, with getters & setters (default)', + 2 => 'Mutable, with public properties', + 3 => 'Immutable, with getters only', + ]; + + private const TEMPLATE_NAMES = [ + 1 => 'MutableGettersSetters', + 2 => 'MutablePublic', + 3 => 'ImmutableGetters', + ]; + + private const MUTATOR_NAME_PREFIX = 'updateFrom'; + private const ARGUMENT_NAME = 'name'; + private const ARGUMENT_ENTITY = 'entity'; + private const OPTION_STYLE = 'style'; + private const OPTION_MUTATOR = 'mutator'; + + // Did we import assert annotations? + private $assertionsImported = false; + + // Are there differences in the validation constraints between metadata (includes annotations, xml, yaml) and annotations? + private $suspectYamlXmlValidations = false; + + public function __construct( + DoctrineHelper $doctrineHelper, + FileManager $fileManager, + ValidatorInterface $validator = null + ) { + $this->doctrineHelper = $doctrineHelper; + $this->fileManager = $fileManager; + $this->validator = $validator; + } + + public static function getCommandName(): string + { + return 'make:dto'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConf) + { + $command + ->setDescription('Creates a new "data transfer object" (DTO) class from a Doctrine entity') + ->addArgument(self::ARGUMENT_NAME, InputArgument::OPTIONAL, sprintf('The name of the DTO class (e.g. %sData)', Str::asClassName(Str::getRandomTerm()))) + ->addArgument(self::ARGUMENT_ENTITY, InputArgument::OPTIONAL, 'The name of Entity that the DTO will be bound to') + ->addOption(self::OPTION_STYLE, null, InputOption::VALUE_REQUIRED, 'The style of the DTO') + ->addOption(self::OPTION_MUTATOR, null, InputOption::VALUE_REQUIRED, 'Whether a mutator should be added to the entity', true) + ->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeDto.txt')) + ; + + $inputConf->setArgumentAsNonInteractive(self::ARGUMENT_NAME); + $inputConf->setArgumentAsNonInteractive(self::ARGUMENT_ENTITY); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command) + { + if (null === $input->getArgument(self::ARGUMENT_NAME)) { + $argument = $command->getDefinition()->getArgument(self::ARGUMENT_NAME); + $question = $this->createDataClassQuestion($argument->getDescription()); + $value = $io->askQuestion($question); + $input->setArgument(self::ARGUMENT_NAME, $value); + } + + if (null === $input->getArgument(self::ARGUMENT_ENTITY)) { + $argument = $command->getDefinition()->getArgument(self::ARGUMENT_ENTITY); + $question = $this->createEntityClassQuestion($argument->getDescription()); + $value = $io->askQuestion($question); + $input->setArgument(self::ARGUMENT_ENTITY, $value); + } + + $input->setOption(self::OPTION_STYLE, $io->choice( + 'Specify the type of DTO you want:', + self::DTO_STYLES + )); + + $input->setOption(self::OPTION_MUTATOR, $io->confirm( + 'Add mutator method to Entity (to set data from the DTO)?' + )); + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) + { + $dataClassNameDetails = $generator->createClassNameDetails( + $input->getArgument(self::ARGUMENT_NAME), + 'Dto\\', + 'Data' + ); + + $entity = $input->getArgument(self::ARGUMENT_ENTITY); + + $entityDetails = $generator->createClassNameDetails( + $entity, + 'Entity\\' + ); + + // Verify that class is an entity + if (!$this->doctrineHelper->isClassAMappedEntity($entityDetails->getFullName())) { + throw new RuntimeCommandException('The bound class is not a valid doctrine entity.'); + } + + $fields = $this->getFilteredFieldMappings($entityDetails->getFullName()); + + $missingGettersSetters = $this->checkMissingGettersSetters($fields, $entityDetails->getFullName()); + + $entityVars = [ + 'entity_full_class_name' => $entityDetails->getFullName(), + 'entity_class_name' => $entityDetails->getShortName(), + ]; + + $DTOClassPath = $generator->generateClass( + $dataClassNameDetails->getFullName(), + $this->getDtoTemplateName($input), + array_merge( + [ + 'fields' => $fields, + ], + $entityVars + ) + ); + + $generator->writeChanges(); + + $dtoManipulator = $this->createDTOClassManipulator($DTOClassPath, $input); + $this->createProperties($dtoManipulator, $entityDetails, $fields); + + if ($this->shouldCreateConstructor($input)) { + $dtoManipulator->createNamedConstructor($entityDetails, $dataClassNameDetails->getShortName()); + $dtoManipulator->createConstructor(); + } + + $this->fileManager->dumpFile( + $DTOClassPath, + $dtoManipulator->getSourceCode() + ); + + if ($input->getOption(self::OPTION_MUTATOR)) { + $entityClassDetails = new ClassDetails($entityDetails->getFullName()); + $entityPath = $entityClassDetails->getPath(); + $entityManipulator = $this->createEntityClassManipulator($entityPath, $io, false); + + $this->createEntityMutator( + $entityManipulator, + $dataClassNameDetails, + $fields, + $this->shouldGenerateGetters($input) + ); + + $this->fileManager->dumpFile($entityPath, $entityManipulator->getSourceCode()); + } + + $this->writeSuccessMessage($io); + + if (true === $this->assertionsImported) { + $io->note([ + 'The maker imported assertion annotations.', + 'Consider removing them from the entity or make sure to keep them updated in both places.', + ]); + } + + if (true === $this->suspectYamlXmlValidations) { + $io->note([ + 'The entity possibly uses Yaml/Xml validators.', + 'Make sure to update the validations to include the new DTO class.', + ]); + } + + if ($missingGettersSetters) { + $io->note([ + 'The maker found missing getters/setters for properties in the entity.', + 'Please review the generated DTO for @todo comments.', + ]); + } + + $nextSteps = [ + 'Next:', + sprintf('- Review the new DTO %s', $DTOClassPath), + $input->getOption(self::OPTION_MUTATOR) ? sprintf( + '- Review the generated mutator method %s() in %s', + $entityDetails->getShortName().'::'.self::MUTATOR_NAME_PREFIX.$dataClassNameDetails->getShortName(), + $this->fileManager->relativizePath($entityClassDetails->getPath()) + ) : null, + 'Then: Create a form for this DTO by running:', + sprintf('$ php bin/console make:form %s', $entityDetails->getShortName()), + sprintf('and enter \\%s', $dataClassNameDetails->getFullName()), + '', + 'Find the documentation at https://symfony.com/doc/current/forms/data_transfer_objects.html', + ]; + + $io->text($nextSteps); + } + + public function configureDependencies(DependencyBuilder $dependencies) + { + $dependencies->addClassDependency( + Validation::class, + 'validator', + // add as an optional dependency: the user *probably* wants validation + false + ); + } + + /** + * Get field mappings from class metadata (used to copy annotations and generate properties). + */ + private function getFilteredFieldMappings(string $entityClassName): array + { + /** + * @var ClassMetaData + */ + $metaData = $this->doctrineHelper->getMetadata($entityClassName); + + return $this->filterIdentifiersFromFields($metaData->fieldMappings, $metaData); + } + + private function filterIdentifiersFromFields($fields, $metaData): array + { + return array_filter($fields, function ($field) use ($metaData) { + return !$metaData->isIdentifier($field['fieldName']); + }); + } + + private function checkMissingGettersSetters(array &$fields, string $entityClassName): bool + { + $missingGettersSetters = false; + foreach (array_keys($fields) as $fieldName) { + $fields[$fieldName]['hasSetter'] = $this->entityHasSetter($entityClassName, $fieldName); + $fields[$fieldName]['hasGetter'] = $this->entityHasGetter($entityClassName, $fieldName); + + if (!$fields[$fieldName]['hasGetter'] || !$fields[$fieldName]['hasSetter']) { + $missingGettersSetters = true; + } + } + + return $missingGettersSetters; + } + + private function createEntityClassQuestion(string $questionText): Question + { + $question = new Question($questionText); + $entities = $this->doctrineHelper->getEntitiesForAutocomplete(); + $question->setAutocompleterValues($entities); + $question->setMaxAttempts(10); + $question->setValidator(function ($answer) use ($entities) {return Validator::existsOrNull($answer, $entities); }); + + return $question; + } + + private function createDataClassQuestion(string $questionText): Question + { + $question = new Question($questionText); + $question->setValidator([Validator::class, 'notBlank']); + $question->setMaxAttempts(10); + + return $question; + } + + private function createProperties($dtoManipulator, $entityDetails, $fields) + { + foreach ($fields as $fieldName => $mapping) { + $annotationReader = new AnnotationReader(); + + $fullClassName = $mapping['declared'] ?? $entityDetails->getFullName(); + + // Property Annotations + $reflectionProperty = new \ReflectionProperty($fullClassName, $fieldName); + $propertyAnnotations = $annotationReader->getPropertyAnnotations($reflectionProperty); + + // Passed to the ClassManipulator + $annotationComments = []; + + // Count the Constraints for comparison with the Validator + $constraintCount = 0; + + foreach ($propertyAnnotations as $annotation) { + // We want to copy the asserts, so look for their interface + if ($annotation instanceof Constraint) { + // Set flag for use in result message + $this->assertionsImported = true; + ++$constraintCount; + $annotationComments[] = $dtoManipulator->buildAnnotationLine('@Assert\\'.(new \ReflectionClass($annotation))->getShortName(), $this->getAnnotationAsString($annotation)); + } + } + + // Compare the amount of constraints in annotations with those in the complete validator-metadata for the entity + if (false === $this->hasAsManyValidations($entityDetails->getFullName(), $fieldName, $constraintCount)) { + $this->suspectYamlXmlValidations = true; + } + + $dtoManipulator->addEntityField($fieldName, $mapping, $annotationComments); + } + + // Add use statement for validation annotations if necessary + if (true == $this->assertionsImported) { + // The use of an alias is not supposed, but it works fine and we don't use the returned value. + $dtoManipulator->addUseStatementIfNecessary('Symfony\Component\Validator\Constraints as Assert'); + } + } + + private function createEntityMutator(ClassSourceManipulator $entityManipulator, ClassNameDetails $dataClassNameDetails, array $fields, bool $dtoHasGetters) + { + $dataClassUseName = $entityManipulator->addUseStatementIfNecessary($dataClassNameDetails->getFullName()); + + $updateFromMethodBuilder = $entityManipulator->createMethodBuilder(self::MUTATOR_NAME_PREFIX.$dataClassNameDetails->getShortName(), null, true); + $updateFromMethodBuilder->addParam( + (new \PhpParser\Builder\Param(lcfirst($dataClassNameDetails->getShortName())))->setTypeHint($dataClassUseName) + ); + + $methodBody = ' $mapping) { + $assignedValue = ($dtoHasGetters) ? '$'.lcfirst($dataClassNameDetails->getShortName()).'->get'.Str::asCamelCase($propertyName).'()' : '$'.lcfirst($dataClassNameDetails->getShortName()).'->'.$propertyName; + if (false === $mapping['hasSetter']) { + $methodBody .= '$this->'.$propertyName.' = '.$assignedValue.';'.PHP_EOL; + } else { + $methodBody .= '$this->set'.Str::asCamelCase($propertyName).'('.$assignedValue.');'.PHP_EOL; + } + } + + $entityManipulator->addMethodBody($updateFromMethodBuilder, $methodBody); + $entityManipulator->addMethodBuilder($updateFromMethodBuilder); + } + + private function createDTOClassManipulator(string $classPath, InputInterface $input): DTOClassSourceManipulator + { + return new DTOClassSourceManipulator( + $this->fileManager->getFileContents($classPath), + // overwrite existing methods + true, + // use annotations + true, + // use fluent mutators + true, + // generate getters? + $this->shouldGenerateGetters($input), + // generate setters? + $this->shouldGenerateSetters($input), + // Public properties? + $this->shouldGeneratePublicProperties($input), + // Constructor? + $this->shouldCreateConstructor($input) + ); + } + + private function createEntityClassManipulator(string $path, ConsoleStyle $io, bool $overwrite): ClassSourceManipulator + { + $manipulator = new ClassSourceManipulator($this->fileManager->getFileContents($path), $overwrite); + $manipulator->setIo($io); + + return $manipulator; + } + + private function getAnnotationAsString(Constraint $annotation) + { + // We typecast, because array_diff expects arrays and both functions can return null. + return array_diff((array) get_object_vars($annotation), (array) get_class_vars(\get_class($annotation))); + } + + private function hasAsManyValidations($entityClassname, $fieldName, $constraintCount) + { + if (null === $this->validator) { + return 0 == $constraintCount; + } + + // lazily build validatorClassMetadata + if (null === $this->validatorClassMetadata) { + $this->validatorClassMetadata = $this->validator->getMetadataFor($entityClassname); + } + + $propertyMetadata = $this->validatorClassMetadata->getPropertyMetadata($fieldName); + + $metadataConstraintCount = 0; + foreach ($propertyMetadata as $metadata) { + if (isset($metadata->constraints)) { + $metadataConstraintCount += is_countable($metadata->constraints) ? \count($metadata->constraints) : 0; + } + } + + return $metadataConstraintCount == $constraintCount; + } + + private function entityHasGetter($entityClassName, $propertyName): bool + { + return method_exists($entityClassName, sprintf('get%s', Str::asCamelCase($propertyName))); + } + + private function entityHasSetter($entityClassName, $propertyName): bool + { + return method_exists($entityClassName, sprintf('set%s', Str::asCamelCase($propertyName))); + } + + private function getDtoTemplateName(InputInterface $input): string + { + return __DIR__ + .'/../Resources/skeleton/dto/' + .self::TEMPLATE_NAMES[array_search($input->getOption(self::OPTION_STYLE), self::DTO_STYLES)] + .'Dto.tpl.php'; + } + + private function shouldGeneratePublicProperties(InputInterface $input): bool + { + return 2 === array_search($input->getOption(self::OPTION_STYLE), self::DTO_STYLES); + } + + private function shouldGenerateSetters(InputInterface $input): bool + { + return 1 === array_search($input->getOption(self::OPTION_STYLE), self::DTO_STYLES); + } + + private function shouldGenerateGetters(InputInterface $input): bool + { + return \in_array(array_search($input->getOption(self::OPTION_STYLE), self::DTO_STYLES), [1, 3]); + } + + private function shouldCreateConstructor(InputInterface $input): bool + { + return 3 === array_search($input->getOption(self::OPTION_STYLE), self::DTO_STYLES); + } +} diff --git a/src/Resources/config/makers.xml b/src/Resources/config/makers.xml index f591b7fcb..7780cc065 100644 --- a/src/Resources/config/makers.xml +++ b/src/Resources/config/makers.xml @@ -7,6 +7,13 @@ + + + + + + + diff --git a/src/Resources/help/MakeDto.txt b/src/Resources/help/MakeDto.txt new file mode 100644 index 000000000..dcbd2b272 --- /dev/null +++ b/src/Resources/help/MakeDto.txt @@ -0,0 +1,5 @@ +The %command.name% command generates a new data transfer object class. + +php %command.full_name% DtoClassName TargetEntityClassName + +If the argument is missing, the command will ask for the classname and entity interactively. diff --git a/src/Resources/skeleton/dto/ImmutableGettersDto.tpl.php b/src/Resources/skeleton/dto/ImmutableGettersDto.tpl.php new file mode 100644 index 000000000..765bef422 --- /dev/null +++ b/src/Resources/skeleton/dto/ImmutableGettersDto.tpl.php @@ -0,0 +1,11 @@ + + +namespace ; + +/** + * Data transfer object for . + */ +class + +{ +} diff --git a/src/Resources/skeleton/dto/MutableGettersSettersDto.tpl.php b/src/Resources/skeleton/dto/MutableGettersSettersDto.tpl.php new file mode 100644 index 000000000..e03020b0f --- /dev/null +++ b/src/Resources/skeleton/dto/MutableGettersSettersDto.tpl.php @@ -0,0 +1,34 @@ + + + +namespace ; + + +use ; + + +/** + * Data transfer object for . + */ +class + +{ + /** + * Create DTO, optionally extracting data from a model. + */ + public function __construct( $ = null) + { + if ($) { + $mapping): ?> + + // @todo implement getter on the Entity + //$this->set($->get()); + + $this->set($->get()); + + + } + } +} diff --git a/src/Resources/skeleton/dto/MutablePublicDto.tpl.php b/src/Resources/skeleton/dto/MutablePublicDto.tpl.php new file mode 100644 index 000000000..4f772d7e8 --- /dev/null +++ b/src/Resources/skeleton/dto/MutablePublicDto.tpl.php @@ -0,0 +1,34 @@ + + + +namespace ; + + +use ; + + +/** + * Data transfer object for . + */ +class + +{ + /** + * Create DTO, optionally extracting data from a model. + */ + public function __construct( $ = null) + { + if ($) { + $mapping): ?> + + // @todo implement getter on the Entity + //$this-> = $->get(); + + $this-> = $->get(); + + + } + } +} diff --git a/src/Util/DTOClassSourceManipulator.php b/src/Util/DTOClassSourceManipulator.php new file mode 100644 index 000000000..8e86a2a5e --- /dev/null +++ b/src/Util/DTOClassSourceManipulator.php @@ -0,0 +1,904 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Util; + +use PhpParser\Builder; +use PhpParser\Builder\Param; +use PhpParser\BuilderHelpers; +use PhpParser\Lexer; +use PhpParser\Node; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; +use PhpParser\Parser; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\Str; + +/** + * @internal + */ +final class DTOClassSourceManipulator +{ + const CONTEXT_OUTSIDE_CLASS = 'outside_class'; + const CONTEXT_CLASS = 'class'; + const CONTEXT_CLASS_METHOD = 'class_method'; + + private $overwrite; + private $useAnnotations; + private $fluentMutators; + private $generateGetters; + private $generateSetters; + private $generatePublicProperties; + private $generateConstructor; + private $parser; + private $lexer; + private $printer; + /** @var ConsoleStyle|null */ + private $io; + + private $sourceCode; + private $oldStmts; + private $oldTokens; + private $newStmts; + + private $pendingComments = []; + + private $pendingConstructorParams = []; + + public function __construct(string $sourceCode, bool $overwrite = false, bool $useAnnotations = true, bool $fluentMutators = true, bool $generateGetters = true, bool $generateSetters = true, bool $generatePublicProperties = true, bool $generateConstructor = true) + { + $this->overwrite = $overwrite; + $this->useAnnotations = $useAnnotations; + $this->generateGetters = $generateGetters; + $this->generateSetters = $generateSetters; + $this->generatePublicProperties = $generatePublicProperties; + $this->generateConstructor = $generateConstructor; + $this->fluentMutators = $fluentMutators; + $this->lexer = new Lexer\Emulative([ + 'usedAttributes' => [ + 'comments', + 'startLine', 'endLine', + 'startTokenPos', 'endTokenPos', + ], + ]); + $this->parser = new Parser\Php7($this->lexer); + $this->printer = new PrettyPrinter(); + + $this->setSourceCode($sourceCode); + } + + public function setIo(ConsoleStyle $io) + { + $this->io = $io; + } + + public function getSourceCode(): string + { + return $this->sourceCode; + } + + public function addEntityField(string $propertyName, array $columnOptions, array $comments = []) + { + $typeHint = $this->getEntityTypeHint($columnOptions['type']); + $nullable = $columnOptions['nullable'] ?? false; + $isId = (bool) ($columnOptions['id'] ?? false); + $defaultValue = null; + if ('array' === $typeHint) { + $defaultValue = new Node\Expr\Array_([], ['kind' => Node\Expr\Array_::KIND_SHORT]); + } + $this->addProperty($propertyName, $comments, $defaultValue); + + if ($this->generateGetters) { + $this->addGetter( + $propertyName, + $typeHint, + // getter methods always have nullable return values + // because even though these are required in the db, they may not be set yet + true + ); + } + + // don't generate setters for id fields + if ($this->generateSetters && !$isId) { + $this->addSetter($propertyName, $typeHint, $nullable); + } + + if ($this->generateConstructor) { + $this->pendingConstructorParams[$propertyName] = [ + 'name' => $propertyName, + 'nullable' => $nullable, + 'type' => $typeHint, + 'default' => $defaultValue, + 'isId' => $isId, + ]; + } + } + + public function addAccessorMethod(string $propertyName, string $methodName, $returnType, bool $isReturnTypeNullable, array $commentLines = [], $typeCast = null) + { + $this->addCustomGetter($propertyName, $methodName, $returnType, $isReturnTypeNullable, $commentLines, $typeCast); + } + + public function addGetter(string $propertyName, $returnType, bool $isReturnTypeNullable, array $commentLines = []) + { + $methodName = 'get'.Str::asCamelCase($propertyName); + + $this->addCustomGetter($propertyName, $methodName, $returnType, $isReturnTypeNullable, $commentLines); + } + + public function addSetter(string $propertyName, $type, bool $isNullable, array $commentLines = []) + { + $builder = $this->createSetterNodeBuilder($propertyName, $type, $isNullable, $commentLines); + $this->makeMethodFluent($builder); + $this->addMethod($builder->getNode()); + } + + /** + * @param Node[] $params + */ + public function addConstructor(array $params, string $methodBody) + { + if (null !== $this->getConstructorNode()) { + throw new \LogicException('Constructor already exists.'); + } + + $methodBuilder = $this->createMethodBuilder('__construct', null, false); + + $this->addMethodParams($methodBuilder, $params); + + $this->addMethodBody($methodBuilder, $methodBody); + + $this->addNodeAfterProperties($methodBuilder->getNode()); + $this->updateSourceCodeFromNewStmts(); + } + + public function addMethodBuilder(Builder\Method $methodBuilder) + { + $this->addMethod($methodBuilder->getNode()); + } + + public function addMethodBody(Builder\Method $methodBuilder, string $methodBody) + { + $nodes = $this->parser->parse($methodBody); + $methodBuilder->addStmts($nodes); + } + + public function createMethodBuilder(string $methodName, $returnType, bool $isReturnTypeNullable, array $commentLines = []): Builder\Method + { + $methodNodeBuilder = (new Builder\Method($methodName)) + ->makePublic() + ; + + if (null !== $returnType) { + $methodNodeBuilder->setReturnType($isReturnTypeNullable ? new Node\NullableType($returnType) : $returnType); + } + + if ($commentLines) { + $methodNodeBuilder->setDocComment($this->createDocBlock($commentLines)); + } + + return $methodNodeBuilder; + } + + public function createMethodLevelCommentNode(string $comment) + { + return $this->createSingleLineCommentNode($comment, self::CONTEXT_CLASS_METHOD); + } + + public function createMethodLevelBlankLine() + { + return $this->createBlankLineNode(self::CONTEXT_CLASS_METHOD); + } + + public function addProperty(string $name, array $annotationLines = [], $defaultValue = null) + { + if ($this->propertyExists($name)) { + // we never overwrite properties + return; + } + $newPropertyBuilder = new Builder\Property($name); + + if ($this->generatePublicProperties) { + $newPropertyBuilder->makePublic(); + } else { + $newPropertyBuilder->makePrivate(); + } + + if ($annotationLines && $this->useAnnotations) { + $newPropertyBuilder->setDocComment($this->createDocBlock($annotationLines)); + } + + if (null !== $defaultValue) { + $newPropertyBuilder->setDefault($defaultValue); + } + $newPropertyNode = $newPropertyBuilder->getNode(); + + $this->addNodeAfterProperties($newPropertyNode); + } + + private function addCustomGetter(string $propertyName, string $methodName, $returnType, bool $isReturnTypeNullable, array $commentLines = [], $typeCast = null) + { + $propertyFetch = new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $propertyName); + + if (null !== $typeCast) { + switch ($typeCast) { + case 'string': + $propertyFetch = new Node\Expr\Cast\String_($propertyFetch); + break; + default: + // implement other cases if/when the library needs them + throw new \Exception('Not implemented'); + } + } + + $getterNodeBuilder = (new Builder\Method($methodName)) + ->makePublic() + ->addStmt( + new Node\Stmt\Return_($propertyFetch) + ) + ; + + if (null !== $returnType) { + $getterNodeBuilder->setReturnType($isReturnTypeNullable ? new Node\NullableType($returnType) : $returnType); + } + + if ($commentLines) { + $getterNodeBuilder->setDocComment($this->createDocBlock($commentLines)); + } + + $this->addMethod($getterNodeBuilder->getNode()); + } + + private function createSetterNodeBuilder(string $propertyName, $type, bool $isNullable, array $commentLines = []) + { + $methodName = 'set'.Str::asCamelCase($propertyName); + $setterNodeBuilder = (new Builder\Method($methodName))->makePublic(); + + if ($commentLines) { + $setterNodeBuilder->setDocComment($this->createDocBlock($commentLines)); + } + + $paramBuilder = new Builder\Param($propertyName); + if (null !== $type) { + $paramBuilder->setTypeHint($isNullable ? new Node\NullableType($type) : $type); + } + $setterNodeBuilder->addParam($paramBuilder->getNode()); + + $setterNodeBuilder->addStmt( + new Node\Stmt\Expression(new Node\Expr\Assign( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $propertyName), + new Node\Expr\Variable($propertyName) + )) + ); + + return $setterNodeBuilder; + } + + /** + * Modified to public from ClassSourceManipulator. + * + * @param string $annotationClass The annotation: e.g. "@ORM\Column" + * @param array $options Key-value pair of options for the annotation + * + * @return string + */ + public function buildAnnotationLine(string $annotationClass, array $options) + { + $formattedOptions = array_map(function ($option, $value) { + if (\is_array($value)) { + if (!isset($value[0])) { + return sprintf('%s={%s}', $option, implode(', ', array_map(function ($val, $key) { + return sprintf('"%s" = %s', $key, $this->quoteAnnotationValue($val)); + }, $value, array_keys($value)))); + } + + return sprintf('%s={%s}', $option, implode(', ', array_map(function ($val) { + return $this->quoteAnnotationValue($val); + }, $value))); + } + + return sprintf('%s=%s', $option, $this->quoteAnnotationValue($value)); + }, array_keys($options), array_values($options)); + + return sprintf('%s(%s)', $annotationClass, implode(', ', $formattedOptions)); + } + + private function quoteAnnotationValue($value) + { + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (null === $value) { + return 'null'; + } + + if (\is_int($value)) { + return $value; + } + + if (\is_array($value)) { + throw new \Exception('Invalid value: loop before quoting.'); + } + + return sprintf('"%s"', $value); + } + + private function addStatementToConstructor(Node\Stmt $stmt) + { + if (!$this->getConstructorNode()) { + $constructorNode = (new Builder\Method('__construct'))->makePublic()->getNode(); + + // add call to parent::__construct() if there is a need to + try { + $ref = new \ReflectionClass($this->getThisFullClassName()); + + if ($ref->getParentClass() && $ref->getParentClass()->getConstructor()) { + $constructorNode->stmts[] = new Node\Stmt\Expression( + new Node\Expr\StaticCall(new Node\Name('parent'), new Node\Identifier('__construct')) + ); + } + } catch (\ReflectionException $e) { + } + + $this->addNodeAfterProperties($constructorNode); + } + + $constructorNode = $this->getConstructorNode(); + $constructorNode->stmts[] = $stmt; + $this->updateSourceCodeFromNewStmts(); + } + + /** + * @return Node\Stmt\ClassMethod|null + * + * @throws \Exception + */ + private function getConstructorNode() + { + foreach ($this->getClassNode()->stmts as $classNode) { + if ($classNode instanceof Node\Stmt\ClassMethod && '__construct' == $classNode->name) { + return $classNode; + } + } + + return null; + } + + /** + * Modified to public from ClassSourceManipulator. + * + * @return string The alias to use when referencing this class + */ + public function addUseStatementIfNecessary(string $class): string + { + $shortClassName = Str::getShortClassName($class); + if ($this->isInSameNamespace($class)) { + return $shortClassName; + } + + $namespaceNode = $this->getNamespaceNode(); + + $targetIndex = null; + $addLineBreak = false; + $lastUseStmtIndex = null; + foreach ($namespaceNode->stmts as $index => $stmt) { + if ($stmt instanceof Node\Stmt\Use_) { + // I believe this is an array to account for use statements with {} + foreach ($stmt->uses as $use) { + $alias = $use->alias ? $use->alias->name : $use->name->getLast(); + + // the use statement already exists? Don't add it again + if ($class === (string) $use->name) { + return $alias; + } + + if ($alias === $shortClassName) { + // we have a conflicting alias! + // to be safe, use the fully-qualified class name + // everywhere and do not add another use statement + return '\\'.$class; + } + } + + // if $class is alphabetically before this use statement, place it before + // only set $targetIndex the first time you find it + if (null === $targetIndex && Str::areClassesAlphabetical($class, (string) $stmt->uses[0]->name)) { + $targetIndex = $index; + } + + $lastUseStmtIndex = $index; + } elseif ($stmt instanceof Node\Stmt\Class_) { + if (null !== $targetIndex) { + // we already found where to place the use statement + + break; + } + + // we hit the class! If there were any use statements, + // then put this at the bottom of the use statement list + if (null !== $lastUseStmtIndex) { + $targetIndex = $lastUseStmtIndex + 1; + } else { + $targetIndex = $index; + $addLineBreak = true; + } + + break; + } + } + + if (null === $targetIndex) { + throw new \Exception('Could not find a class!'); + } + + $newUseNode = (new Builder\Use_($class, Node\Stmt\Use_::TYPE_NORMAL))->getNode(); + array_splice( + $namespaceNode->stmts, + $targetIndex, + 0, + $addLineBreak ? [$newUseNode, $this->createBlankLineNode(self::CONTEXT_OUTSIDE_CLASS)] : [$newUseNode] + ); + + $this->updateSourceCodeFromNewStmts(); + + return $shortClassName; + } + + public function createConstructor() + { + $pendingConstructorParams = $this->getPendingConstructorParams(); + if (!\count($pendingConstructorParams)) { + return; + } + + // sort to put optional params to the end. + uasort($pendingConstructorParams, [$this, 'sortConstructorParams']); + + $params = []; + $methodBody = 'setType($paramOptions['nullable'] ? new Node\NullableType($paramOptions['type']) : $paramOptions['type']); + if (null !== $paramOptions['default']) { + $param->setDefault($paramOptions['default']); + } + $params[] = $param->getNode(); + + $methodBody .= '$this->'.$paramOptions['name'].' = $'.$paramOptions['name'].';'.PHP_EOL; + } + $this->addConstructor($params, $methodBody); + } + + public function createNamedConstructor(ClassNameDetails $entityDetails, $dtoName) + { + $pendingConstructorParams = $this->getPendingConstructorParams(); + if (!\count($pendingConstructorParams)) { + return; + } + + // sort to put optional params to the end. + uasort($pendingConstructorParams, [$this, 'sortConstructorParams']); + + $entityName = $entityDetails->getShortName(); + $this->addUseStatementIfNecessary($entityDetails->getFullName()); + + $methodBody = ' $paramOptions) { + $methodBody .= '$'.lcfirst($entityName).'->get'.Str::asCamelCase($paramOptions['name']).'()'; + $methodBody .= (end($keys) === $key) ? PHP_EOL : ','.PHP_EOL; + } + $methodBody .= ');'.PHP_EOL; + + $methodBuilder = $this->createMethodBuilder('from'.ucfirst($entityName), 'self', false); + $methodBuilder->makeStatic(); + $params = [ + (new Param(lcfirst($entityName)))->setType($entityName)->getNode(), + ]; + + $this->addMethodParams($methodBuilder, $params); + + $this->addMethodBody($methodBuilder, $methodBody); + + $this->addNodeAfterProperties($methodBuilder->getNode()); + $this->updateSourceCodeFromNewStmts(); + } + + private function sortConstructorParams(array $paramA, array $paramB): int + { + if ($paramA['nullable'] || $paramB['nullable']) { + if ($paramA['nullable'] === $paramB['nullable']) { + return 0; + } + + return ($paramA['nullable'] > $paramB['nullable']) ? 1 : -1; + } + if (null !== $paramA['default']) { + return 1; + } + if (null !== $paramB['default']) { + return -1; + } + + return 0; + } + + public function getPendingConstructorParams(): array + { + return $this->pendingConstructorParams; + } + + private function updateSourceCodeFromNewStmts() + { + $newCode = $this->printer->printFormatPreserving( + $this->newStmts, + $this->oldStmts, + $this->oldTokens + ); + + // replace the 3 "fake" items that may be in the code (allowing for different indentation) + $newCode = preg_replace('/(\ |\t)*private\ \$__EXTRA__LINE;/', '', $newCode); + $newCode = preg_replace('/use __EXTRA__LINE;/', '', $newCode); + $newCode = preg_replace('/(\ |\t)*\$__EXTRA__LINE;/', '', $newCode); + + // process comment lines + foreach ($this->pendingComments as $i => $comment) { + // sanity check + $placeholder = sprintf('$__COMMENT__VAR_%d;', $i); + if (false === strpos($newCode, $placeholder)) { + // this can happen if a comment is createSingleLineCommentNode() + // is called, but then that generated code is ultimately not added + continue; + } + + $newCode = str_replace($placeholder, '// '.$comment, $newCode); + } + $this->pendingComments = []; + + $this->setSourceCode($newCode); + } + + private function setSourceCode(string $sourceCode) + { + $this->sourceCode = $sourceCode; + $this->oldStmts = $this->parser->parse($sourceCode); + $this->oldTokens = $this->lexer->getTokens(); + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NodeVisitor\CloningVisitor()); + $traverser->addVisitor(new NodeVisitor\NameResolver(null, [ + 'replaceNodes' => false, + ])); + $this->newStmts = $traverser->traverse($this->oldStmts); + } + + private function getClassNode(): Node\Stmt\Class_ + { + $node = $this->findFirstNode(function ($node) { + return $node instanceof Node\Stmt\Class_; + }); + + if (!$node) { + throw new \Exception('Could not find class node'); + } + + return $node; + } + + private function getNamespaceNode(): Node\Stmt\Namespace_ + { + $node = $this->findFirstNode(function ($node) { + return $node instanceof Node\Stmt\Namespace_; + }); + + if (!$node) { + throw new \Exception('Could not find namespace node'); + } + + return $node; + } + + /** + * @return Node|null + */ + private function findFirstNode(callable $filterCallback) + { + $traverser = new NodeTraverser(); + $visitor = new NodeVisitor\FirstFindingVisitor($filterCallback); + $traverser->addVisitor($visitor); + $traverser->traverse($this->newStmts); + + return $visitor->getFoundNode(); + } + + /** + * @return Node|null + */ + private function findLastNode(callable $filterCallback, array $ast) + { + $traverser = new NodeTraverser(); + $visitor = new NodeVisitor\FindingVisitor($filterCallback); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + $nodes = $visitor->getFoundNodes(); + $node = end($nodes); + + return false === $node ? null : $node; + } + + private function createBlankLineNode(string $context) + { + switch ($context) { + case self::CONTEXT_OUTSIDE_CLASS: + return (new Builder\Use_('__EXTRA__LINE', Node\Stmt\Use_::TYPE_NORMAL)) + ->getNode() + ; + case self::CONTEXT_CLASS: + return (new Builder\Property('__EXTRA__LINE')) + ->makePrivate() + ->getNode() + ; + case self::CONTEXT_CLASS_METHOD: + return new Node\Expr\Variable('__EXTRA__LINE'); + default: + throw new \Exception('Unknown context: '.$context); + } + } + + private function createSingleLineCommentNode(string $comment, string $context) + { + $this->pendingComments[] = $comment; + switch ($context) { + case self::CONTEXT_OUTSIDE_CLASS: + // just not needed yet + throw new \Exception('not supported'); + case self::CONTEXT_CLASS: + // just not needed yet + throw new \Exception('not supported'); + case self::CONTEXT_CLASS_METHOD: + return BuilderHelpers::normalizeStmt(new Node\Expr\Variable(sprintf('__COMMENT__VAR_%d', \count($this->pendingComments) - 1))); + default: + throw new \Exception('Unknown context: '.$context); + } + } + + private function createDocBlock(array $commentLines) + { + $docBlock = "/**\n"; + foreach ($commentLines as $commentLine) { + if ($commentLine) { + $docBlock .= " * $commentLine\n"; + } else { + // avoid the empty, extra space on blank lines + $docBlock .= " *\n"; + } + } + $docBlock .= "\n */"; + + return $docBlock; + } + + private function addMethod(Node\Stmt\ClassMethod $methodNode) + { + $classNode = $this->getClassNode(); + $methodName = $methodNode->name; + $existingIndex = null; + if ($this->methodExists($methodName)) { + if (!$this->overwrite) { + $this->writeNote(sprintf( + 'Not generating %s::%s(): method already exists', + Str::getShortClassName($this->getThisFullClassName()), + $methodName + )); + + return; + } + + // record, so we can overwrite in the same place + $existingIndex = $this->getMethodIndex($methodName); + } + + $newStatements = []; + + // put new method always at the bottom + if (!empty($classNode->stmts)) { + $newStatements[] = $this->createBlankLineNode(self::CONTEXT_CLASS); + } + + $newStatements[] = $methodNode; + + if (null === $existingIndex) { + // add them to the end! + + $classNode->stmts = array_merge($classNode->stmts, $newStatements); + } else { + array_splice( + $classNode->stmts, + $existingIndex, + 1, + $newStatements + ); + } + + $this->updateSourceCodeFromNewStmts(); + } + + private function makeMethodFluent(Builder\Method $methodBuilder) + { + if (!$this->fluentMutators) { + return; + } + + $methodBuilder + ->addStmt($this->createBlankLineNode(self::CONTEXT_CLASS_METHOD)) + ->addStmt(new Node\Stmt\Return_(new Node\Expr\Variable('this'))) + ; + $methodBuilder->setReturnType('self'); + } + + private function getEntityTypeHint($doctrineType) + { + switch ($doctrineType) { + case 'string': + case 'text': + case 'guid': + return 'string'; + + case 'array': + case 'simple_array': + case 'json': + return 'array'; + + case 'boolean': + return 'bool'; + + case 'integer': + case 'smallint': + case 'bigint': + return 'int'; + + case 'float': + return 'float'; + + case 'datetime': + case 'datetimetz': + case 'date': + case 'time': + return '\\'.\DateTimeInterface::class; + + case 'datetime_immutable': + case 'datetimetz_immutable': + case 'date_immutable': + case 'time_immutable': + return '\\'.\DateTimeImmutable::class; + + case 'dateinterval': + return '\\'.\DateInterval::class; + + case 'object': + case 'decimal': + case 'binary': + case 'blob': + default: + return null; + } + } + + private function isInSameNamespace($class) + { + $namespace = substr($class, 0, strrpos($class, '\\')); + + return $this->getNamespaceNode()->name->toCodeString() === $namespace; + } + + private function getThisFullClassName(): string + { + return (string) $this->getClassNode()->namespacedName; + } + + /** + * Adds this new node where a new property should go. + * + * Useful for adding properties, or adding a constructor. + */ + private function addNodeAfterProperties(Node $newNode) + { + $classNode = $this->getClassNode(); + + // try to add after last property + $targetNode = $this->findLastNode(function ($node) { + return $node instanceof Node\Stmt\Property; + }, [$classNode]); + + // otherwise, try to add after the last constant + if (!$targetNode) { + $targetNode = $this->findLastNode(function ($node) { + return $node instanceof Node\Stmt\ClassConst; + }, [$classNode]); + } + + // add the new property after this node + if ($targetNode) { + $index = array_search($targetNode, $classNode->stmts); + + array_splice( + $classNode->stmts, + $index + 1, + 0, + [$this->createBlankLineNode(self::CONTEXT_CLASS), $newNode] + ); + + $this->updateSourceCodeFromNewStmts(); + + return; + } + + // put right at the beginning of the class + // add an empty line, unless the class is totally empty + if (!empty($classNode->stmts)) { + array_unshift($classNode->stmts, $this->createBlankLineNode(self::CONTEXT_CLASS)); + } + array_unshift($classNode->stmts, $newNode); + $this->updateSourceCodeFromNewStmts(); + } + + private function createNullConstant() + { + return new Node\Expr\ConstFetch(new Node\Name('null')); + } + + private function methodExists(string $methodName): bool + { + return false !== $this->getMethodIndex($methodName); + } + + private function getMethodIndex(string $methodName) + { + foreach ($this->getClassNode()->stmts as $i => $node) { + if ($node instanceof Node\Stmt\ClassMethod && strtolower($node->name->toString()) === strtolower($methodName)) { + return $i; + } + } + + return false; + } + + private function propertyExists(string $propertyName) + { + foreach ($this->getClassNode()->stmts as $i => $node) { + if ($node instanceof Node\Stmt\Property && $node->props[0]->name->toString() === $propertyName) { + return true; + } + } + + return false; + } + + private function addMethodParams(Builder\Method $methodBuilder, array $params) + { + foreach ($params as $param) { + $methodBuilder->addParam($param); + } + } + + private function writeNote(string $note) + { + if (null !== $this->io) { + $this->io->text($note); + } + } +} diff --git a/tests/Maker/MakeDtoTest.php b/tests/Maker/MakeDtoTest.php new file mode 100644 index 000000000..9990026aa --- /dev/null +++ b/tests/Maker/MakeDtoTest.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Maker; + +use Symfony\Bundle\MakerBundle\Maker\MakeDto; +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestDetails; + +class MakeDtoTest extends MakerTestCase +{ + public function getTestDetails() + { + yield 'dto_annotations' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + 1, + // Add mutator to Entity (default) + '', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->addExtraDependencies('validator') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoAnnotations') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + $this->assertContains('\\App\\Dto\\TaskData', $output); + }), + ]; + + yield 'dto_composite_id' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable public (for simplicity) + 2, + // Add mutator to Entity (default) + '', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoCompositeId') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('\\App\\Dto\\TaskData', $output); + }), + ]; + + yield 'dto_getters_setters' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable, with getters & setters + 1, + // Add mutator to Entity (default) + '', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoGettersSetters') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + }), + ]; + + yield 'dto_invalid_entity' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable, with getters & setters + 1, + // Add mutator to Entity (default) + '', + ]) + // bound class, can not use "Task" because invalid entity is not in autocomplete + ->setArgumentsString('TaskData \\App\\Entity\\Task') + ->addExtraDependencies('doctrine') + ->setCommandAllowedToFail(true) + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoInvalidEntity') + ->assert(function (string $output) { + $this->assertContains('The bound class is not a valid doctrine entity.', $output); + }), + ]; + + yield 'dto_immutable' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Immutable, with getters only + 3, + // Add mutator to Entity (default) + '', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoImmutable') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + $this->assertContains('\\App\\Dto\\TaskData', $output); + }), + ]; + + yield 'dto_mapped_super_class' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable public (for simplicity) + 2, + // Add mutator to Entity (default) + '', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoMappedSuperClass') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + $this->assertContains('\\App\\Dto\\TaskData', $output); + }), + ]; + + yield 'dto_missing_getters_setters' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable, with getters & setters + 1, + // Add mutator to Entity + 'yes', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoMissingGettersSetters') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + $this->assertContains('\\App\\Dto\\TaskData', $output); + $this->assertContains('The maker found missing getters/setters for properties in the entity.', $output); + }), + ]; + + yield 'dto_mutator' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable public (for simplicity) + 2, + // Add mutator to Entity + 'yes', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoMutator') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + }), + ]; + + yield 'dto_no_mutator' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable public (for simplicity) + 2, + // Add no mutator to Entity + 'no', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoNoMutator') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + }), + ]; + + yield 'dto_public' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable public + 2, + // Add mutator to Entity (default) + '', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoPublic') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + }), + ]; + + yield 'dto_validator_yaml_xml' => [MakerTestDetails::createTest( + $this->getMakerInstance(MakeDto::class), + [ + // Mutable public (for simplicity) + 2, + // Add mutator to Entity (default) + '', + ]) + ->setArgumentsString('TaskData Task') + ->addExtraDependencies('doctrine') + ->addExtraDependencies('validator') + ->addExtraDependencies('yaml') + ->setFixtureFilesPath(__DIR__.'/../fixtures/MakeDtoValidatorYamlXml') + ->assert(function (string $output) { + $this->assertContains('created: src/Dto/TaskData.php', $output); + $this->assertContains('updated: src/Dto/TaskData.php', $output); + $this->assertContains('The entity possibly uses Yaml/Xml validators.', $output); + }), + ]; + } +} diff --git a/tests/Util/DTOClassSourceManipulatorTest.php b/tests/Util/DTOClassSourceManipulatorTest.php new file mode 100644 index 000000000..ff9649574 --- /dev/null +++ b/tests/Util/DTOClassSourceManipulatorTest.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Util; + +use PHPUnit\Framework\TestCase; +use ReflectionMethod; +use Symfony\Bundle\MakerBundle\Util\DTOClassSourceManipulator; + +class DTOClassSourceManipulatorTest extends TestCase +{ + /** + * @dataProvider getSortConstructorParamsTests + */ + public function testSortConstructorParams(array $params, array $sortedParams) + { + $manipulator = new DTOClassSourceManipulator('setAccessible(true); + + $sortClosure = function (array $paramA, array $paramB) use ($sortMethod, $manipulator) { + return $sortMethod->invoke($manipulator, $paramA, $paramB); + }; + + usort($params, $sortClosure); + + $this->assertSame($params, $sortedParams); + } + + public function getSortConstructorParamsTests() + { + yield 'sort_nullable' => [ + [ + [ + 'name' => 'a', + 'nullable' => 'true', + 'default' => null, + ], + [ + 'name' => 'b', + 'nullable' => false, + 'default' => null, + ], + ], + [ + [ + 'name' => 'b', + 'nullable' => false, + 'default' => null, + ], + [ + 'name' => 'a', + 'nullable' => 'true', + 'default' => null, + ], + ], + ]; + + yield 'sort_default' => [ + [ + [ + 'name' => 'a', + 'nullable' => false, + 'default' => [], + ], + [ + 'name' => 'b', + 'nullable' => false, + 'default' => null, + ], + ], + [ + [ + 'name' => 'b', + 'nullable' => false, + 'default' => null, + ], + [ + 'name' => 'a', + 'nullable' => false, + 'default' => [], + ], + ], + ]; + + yield 'sort_nullable_and_default' => [ + [ + [ + 'name' => 'a', + 'nullable' => false, + 'default' => [], + ], + [ + 'name' => 'b', + 'nullable' => true, + 'default' => null, + ], + ], + [ + [ + 'name' => 'a', + 'nullable' => false, + 'default' => [], + ], + [ + 'name' => 'b', + 'nullable' => true, + 'default' => null, + ], + ], + ]; + + yield 'sort_nullable_and_default_multiple' => [ + [ + [ + 'name' => 'a', + 'nullable' => false, + 'default' => [], + ], + [ + 'name' => 'b', + 'nullable' => true, + 'default' => null, + ], + [ + 'name' => 'c', + 'nullable' => false, + 'default' => null, + ], + ], + [ + [ + 'name' => 'c', + 'nullable' => false, + 'default' => null, + ], + [ + 'name' => 'a', + 'nullable' => false, + 'default' => [], + ], + [ + 'name' => 'b', + 'nullable' => true, + 'default' => null, + ], + ], + ]; + } +} diff --git a/tests/fixtures/MakeDtoAnnotations/src/Entity/Task.php b/tests/fixtures/MakeDtoAnnotations/src/Entity/Task.php new file mode 100644 index 000000000..30de1fa7d --- /dev/null +++ b/tests/fixtures/MakeDtoAnnotations/src/Entity/Task.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + * @Assert\NotBlank() + */ + private $task; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoAnnotations/tests/TaskDataTest.php b/tests/fixtures/MakeDtoAnnotations/tests/TaskDataTest.php new file mode 100644 index 000000000..db9f2849c --- /dev/null +++ b/tests/fixtures/MakeDtoAnnotations/tests/TaskDataTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use Doctrine\Common\Annotations\AnnotationReader; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Validation; + +class TaskDataTest extends KernelTestCase +{ + public function testAnnotations() + { + $annotationReader = new AnnotationReader(); + $reflectionProperty = new \ReflectionProperty(TaskData::class, 'task'); + $propertyAnnotations = $annotationReader->getPropertyAnnotations($reflectionProperty); + $this->assertCount(1, $propertyAnnotations); + $this->assertContainsOnlyInstancesOf(NotBlank::class, $propertyAnnotations); + + $reflectionProperty = new \ReflectionProperty(TaskData::class, 'dueDate'); + $propertyAnnotations = $annotationReader->getPropertyAnnotations($reflectionProperty); + $this->assertCount(0, $propertyAnnotations); + } + + public function testValidation() + { + $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(); + $task = new TaskData; + $errors = $validator->validate($task); + $this->assertEquals(1, count($errors)); + $task->setTask('foo'); + $errors = $validator->validate($task); + $this->assertEquals(0, count($errors)); + } +} diff --git a/tests/fixtures/MakeDtoCompositeId/src/Entity/Task.php b/tests/fixtures/MakeDtoCompositeId/src/Entity/Task.php new file mode 100644 index 000000000..eaacfe4e9 --- /dev/null +++ b/tests/fixtures/MakeDtoCompositeId/src/Entity/Task.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\Column(type="string", length=255, nullable=false) + */ + private $id; + + /** + * @ORM\Id + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $group; + + public function __construct() + { + $this->id = uniqid(); + } + + /** + * Get the value of group. + */ + public function getGroup() + { + return $this->group; + } + + /** + * Set the value of group. + * + * @return self + */ + public function setGroup($group) + { + $this->group = $group; + + return $this; + } + + /** + * Get the value of id. + */ + public function getId() + { + return $this->id; + } + + /** + * Set the value of id. + * + * @return self + */ + public function setId($id) + { + $this->id = $id; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoCompositeId/tests/TaskDataTest.php b/tests/fixtures/MakeDtoCompositeId/tests/TaskDataTest.php new file mode 100644 index 000000000..a10a1bb61 --- /dev/null +++ b/tests/fixtures/MakeDtoCompositeId/tests/TaskDataTest.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class TaskDataTest extends KernelTestCase +{ + public function testCompositeIdEmitted() + { + // the Entity has a composite Id - test that the attributes are both omitted from the DTO + $this->assertClassNotHasAttribute('id', TaskData::class); + $this->assertClassNotHasAttribute('group', TaskData::class); + } +} diff --git a/tests/fixtures/MakeDtoGettersSetters/src/Entity/Task.php b/tests/fixtures/MakeDtoGettersSetters/src/Entity/Task.php new file mode 100644 index 000000000..40734ed1c --- /dev/null +++ b/tests/fixtures/MakeDtoGettersSetters/src/Entity/Task.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $task; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoGettersSetters/tests/TaskDataTest.php b/tests/fixtures/MakeDtoGettersSetters/tests/TaskDataTest.php new file mode 100644 index 000000000..8afde4b20 --- /dev/null +++ b/tests/fixtures/MakeDtoGettersSetters/tests/TaskDataTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use App\Entity\Task; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class TaskDataTest extends KernelTestCase +{ + public function testGetters() + { + $this->assertTrue(method_exists(TaskData::class, 'setTask')); + $this->assertTrue(method_exists(TaskData::class, 'getTask')); + + $this->assertTrue(method_exists(TaskData::class, 'setDueDate')); + $this->assertTrue(method_exists(TaskData::class, 'getDueDate')); + } + + public function testConstructor() + { + $this->assertTrue(method_exists(TaskData::class, '__construct')); + + $taskEntity = new Task(); + $taskEntity->setTask('Acme'); + $taskEntity->setDueDate(new \DateTime('2018-01-29 01:30')); + + $taskData = new TaskData($taskEntity); + + $this->assertEquals($taskEntity->getTask(), $taskData->getTask()); + $this->assertEquals($taskEntity->getDueDate(), $taskData->getDueDate()); + + $this->assertEquals($taskData->getTask(), 'Acme'); + $this->assertEquals($taskData->getDueDate(), new \DateTime('2018-01-29 01:30')); + } +} diff --git a/tests/fixtures/MakeDtoImmutable/src/Entity/Task.php b/tests/fixtures/MakeDtoImmutable/src/Entity/Task.php new file mode 100644 index 000000000..40734ed1c --- /dev/null +++ b/tests/fixtures/MakeDtoImmutable/src/Entity/Task.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $task; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoImmutable/tests/TaskDataTest.php b/tests/fixtures/MakeDtoImmutable/tests/TaskDataTest.php new file mode 100644 index 000000000..1af467db3 --- /dev/null +++ b/tests/fixtures/MakeDtoImmutable/tests/TaskDataTest.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use App\Entity\Task; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class TaskDataTest extends KernelTestCase +{ + public function testSetters() + { + $this->assertFalse(method_exists(TaskData::class, 'setTask')); + $this->assertFalse(method_exists(TaskData::class, 'setDueDate')); + } + + public function testGetters() + { + $this->assertTrue(method_exists(TaskData::class, 'getTask')); + $this->assertTrue(method_exists(TaskData::class, 'getDueDate')); + } + + public function testConstructor() + { + $this->assertTrue(method_exists(TaskData::class, '__construct')); + + $taskData = new TaskData('Acme', new \DateTime('2018-01-29 01:30')); + + $this->assertEquals($taskData->getTask(), 'Acme'); + $this->assertEquals($taskData->getDueDate(), new \DateTime('2018-01-29 01:30')); + } + + public function testNamedConstructor() + { + $this->assertTrue(method_exists(TaskData::class, 'fromTask')); + + $taskEntity = new Task; + $taskEntity->setTask('Acme'); + $taskEntity->setDueDate(new \DateTime('2018-01-29 01:30')); + $taskData = TaskData::fromTask($taskEntity); + + $this->assertEquals($taskData->getTask(), 'Acme'); + $this->assertEquals($taskData->getDueDate(), new \DateTime('2018-01-29 01:30')); + } +} diff --git a/tests/fixtures/MakeDtoInvalidEntity/src/Entity/Task.php b/tests/fixtures/MakeDtoInvalidEntity/src/Entity/Task.php new file mode 100644 index 000000000..b80214929 --- /dev/null +++ b/tests/fixtures/MakeDtoInvalidEntity/src/Entity/Task.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +/** + * Task, without ORM annotations. + */ +class Task +{ + private $id; + + private $task; + + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoMappedSuperClass/src/Entity/Deadline.php b/tests/fixtures/MakeDtoMappedSuperClass/src/Entity/Deadline.php new file mode 100644 index 000000000..e3f30123e --- /dev/null +++ b/tests/fixtures/MakeDtoMappedSuperClass/src/Entity/Deadline.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\MappedSuperclass + */ +class Deadline +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoMappedSuperClass/src/Entity/Task.php b/tests/fixtures/MakeDtoMappedSuperClass/src/Entity/Task.php new file mode 100644 index 000000000..eb417858f --- /dev/null +++ b/tests/fixtures/MakeDtoMappedSuperClass/src/Entity/Task.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class Task extends Deadline +{ + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $task; + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoMappedSuperClass/tests/TaskDataTest.php b/tests/fixtures/MakeDtoMappedSuperClass/tests/TaskDataTest.php new file mode 100644 index 000000000..abcb0bd6e --- /dev/null +++ b/tests/fixtures/MakeDtoMappedSuperClass/tests/TaskDataTest.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use App\Entity\Task; +use Doctrine\Common\Annotations\AnnotationReader; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Validator\Constraints\NotBlank; + +class TaskDataTest extends KernelTestCase +{ + public function testMappedSuperClass() + { + $this->assertClassHasAttribute('task', TaskData::class); + $this->assertClassHasAttribute('dueDate', TaskData::class); + $this->assertClassNotHasAttribute('id', TaskData::class); + + $this->assertFalse(method_exists(TaskData::class, 'setTask')); + $this->assertFalse(method_exists(TaskData::class, 'getTask')); + + $this->assertFalse(method_exists(TaskData::class, 'setDueDate')); + $this->assertFalse(method_exists(TaskData::class, 'getDueDate')); + } + + public function testMutator() + { + $taskEntity = new Task(); + $taskEntity->setTask('Acme'); + $taskEntity->setDueDate(new \DateTime('2018-01-29 01:30')); + + $taskData = new TaskData($taskEntity); + + $this->assertEquals($taskEntity->getTask(), $taskData->task); + $this->assertEquals($taskEntity->getDueDate(), $taskData->dueDate); + + $taskData->task = 'Foo'; + + $taskEntity = new Task(); + $taskEntity->updateFromTaskData($taskData); + + $this->assertEquals($taskEntity->getTask(), $taskData->task); + $this->assertEquals($taskEntity->getDueDate(), $taskData->dueDate); + } +} diff --git a/tests/fixtures/MakeDtoMissingGettersSetters/src/Entity/Task.php b/tests/fixtures/MakeDtoMissingGettersSetters/src/Entity/Task.php new file mode 100644 index 000000000..142352e12 --- /dev/null +++ b/tests/fixtures/MakeDtoMissingGettersSetters/src/Entity/Task.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $task; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $iCanGetNo; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $iCanSetNo; + + public function getId() + { + return $this->id; + } + + /** + * Commented out for the test. + * We want the setter/getter to be missing. + */ + // /** + // * Get the value of task. + // */ + // public function getTask() + // { + // return $this->task; + // } + + // /** + // * Set the value of task. + // * + // * @return self + // */ + // public function setTask($task) + // { + // $this->task = $task; + + // return $this; + // } + + /** + * Commented out for the test. + * We want the getter to be missing. + */ + // /** + // * Get the value of iCanGetNo + // */ + // public function getICanGetNo() + // { + // return $this->iCanGetNo; + // } + + /** + * Set the value of iCanGetNo. + * + * @return self + */ + public function setICanGetNo($iCanGetNo) + { + $this->iCanGetNo = $iCanGetNo; + + return $this; + } + + /** + * Commented out for the test. + * We want the setter to be missing. + */ + // /** + // * Set the value of iCanSetNo. + // * + // * @return self + // */ + // public function setICanSetNo($iCanSetNo) + // { + // $this->iCanSetNo = $iCanSetNo; + + // return $this; + // } + + /** + * Get the value of iCanSetNo. + */ + public function getICanSetNo() + { + return $this->iCanSetNo; + } +} diff --git a/tests/fixtures/MakeDtoMissingGettersSetters/tests/TaskDataTest.php b/tests/fixtures/MakeDtoMissingGettersSetters/tests/TaskDataTest.php new file mode 100644 index 000000000..ba48c3716 --- /dev/null +++ b/tests/fixtures/MakeDtoMissingGettersSetters/tests/TaskDataTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use App\Entity\Task; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class TaskDataTest extends KernelTestCase +{ + public function testSettersGetters() + { + // no setters / getters + $this->assertFalse(method_exists(Task::class, 'setTask')); + $this->assertFalse(method_exists(Task::class, 'getTask')); + $this->assertTrue(method_exists(TaskData::class, 'setTask')); + $this->assertTrue(method_exists(TaskData::class, 'getTask')); + + // missing getter but having setter + $this->assertTrue(method_exists(Task::class, 'setICanGetNo')); + $this->assertFalse(method_exists(Task::class, 'getICanGetNo')); + $this->assertTrue(method_exists(TaskData::class, 'setICanGetNo')); + $this->assertTrue(method_exists(TaskData::class, 'getICanGetNo')); + + // having getter but missing setter + $this->assertFalse(method_exists(Task::class, 'setICanSetNo')); + $this->assertTrue(method_exists(Task::class, 'getICanSetNo')); + $this->assertTrue(method_exists(TaskData::class, 'setICanSetNo')); + $this->assertTrue(method_exists(TaskData::class, 'getICanSetNo')); + } + + public function testConstructorWithMissingGetter() + { + $taskEntity = new Task(); + $taskEntity->setICanGetNo('Satisfaction'); + + // set private task property of the entity via Reflection - we do not have a setter + $entityReflection = new \ReflectionClass($taskEntity); + $taskProperty = $entityReflection->getProperty('task'); + $taskProperty->setAccessible(true); + $taskProperty->setValue($taskEntity, 'Foo'); + + // set the private iCanSetNo property of the entity via Reflection - we do not have a setter + $entityReflection = new \ReflectionClass($taskEntity); + $taskProperty = $entityReflection->getProperty('iCanSetNo'); + $taskProperty->setAccessible(true); + $taskProperty->setValue($taskEntity, 'Satisfaction'); + + $taskData = new TaskData($taskEntity); + + $this->assertEquals('Satisfaction', $taskData->getICanSetNo()); + $this->assertNull($taskData->getICanGetNo()); + $this->assertNull($taskData->getTask()); + } + + public function testMutator() + { + $taskData = new TaskData; + $taskData->setTask('Acme'); + $taskData->setICanGetNo('Satisfaction'); + $taskData->setICanSetNo('Bar'); + + $taskEntity = new Task(); + // will update the entity including the private properties + $taskEntity->updateFromTaskData($taskData); + + // make the private task property accessible to compare the values + $entityReflection = new \ReflectionClass($taskEntity); + $taskProperty = $entityReflection->getProperty('task'); + $taskProperty->setAccessible(true); + $this->assertEquals($taskProperty->getValue($taskEntity), 'Acme'); + + // make the private iCanGetNo property accessible to compare the values + $entityReflection = new \ReflectionClass($taskEntity); + $iCanGetNoProperty = $entityReflection->getProperty('iCanGetNo'); + $iCanGetNoProperty->setAccessible(true); + $this->assertEquals($iCanGetNoProperty->getValue($taskEntity), 'Satisfaction'); + + $this->assertEquals($taskEntity->getICanSetNo(), 'Bar'); + } +} diff --git a/tests/fixtures/MakeDtoMutator/src/Entity/Task.php b/tests/fixtures/MakeDtoMutator/src/Entity/Task.php new file mode 100644 index 000000000..40734ed1c --- /dev/null +++ b/tests/fixtures/MakeDtoMutator/src/Entity/Task.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $task; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoMutator/tests/TaskDataTest.php b/tests/fixtures/MakeDtoMutator/tests/TaskDataTest.php new file mode 100644 index 000000000..03bc1f643 --- /dev/null +++ b/tests/fixtures/MakeDtoMutator/tests/TaskDataTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use App\Entity\Task; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class TaskDataTest extends KernelTestCase +{ + public function testTaskData() + { + $this->assertTrue(method_exists(Task::class, 'updateFromTaskData')); + } + + public function testMutator() + { + $taskData = new TaskData(); + $taskData->dueDate = new \DateTime('2018-01-29 01:30'); + $taskData->task = 'Acme'; + + $taskEntity = new Task(); + $taskEntity->updateFromTaskData($taskData); + + $this->assertEquals($taskEntity->getDueDate(), $taskData->dueDate); + $this->assertEquals($taskEntity->getTask(), $taskData->task); + } +} diff --git a/tests/fixtures/MakeDtoNoMutator/src/Entity/Task.php b/tests/fixtures/MakeDtoNoMutator/src/Entity/Task.php new file mode 100644 index 000000000..40734ed1c --- /dev/null +++ b/tests/fixtures/MakeDtoNoMutator/src/Entity/Task.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $task; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoNoMutator/tests/TaskDataTest.php b/tests/fixtures/MakeDtoNoMutator/tests/TaskDataTest.php new file mode 100644 index 000000000..0668352c7 --- /dev/null +++ b/tests/fixtures/MakeDtoNoMutator/tests/TaskDataTest.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Entity\Task; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class TaskDataTest extends KernelTestCase +{ + public function testNoMutators() + { + $this->assertFalse(method_exists(Task::class, 'updateFromTaskData')); + } +} diff --git a/tests/fixtures/MakeDtoPublic/src/Entity/Task.php b/tests/fixtures/MakeDtoPublic/src/Entity/Task.php new file mode 100644 index 000000000..40734ed1c --- /dev/null +++ b/tests/fixtures/MakeDtoPublic/src/Entity/Task.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + */ + private $task; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoPublic/tests/TaskDataTest.php b/tests/fixtures/MakeDtoPublic/tests/TaskDataTest.php new file mode 100644 index 000000000..091594330 --- /dev/null +++ b/tests/fixtures/MakeDtoPublic/tests/TaskDataTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use App\Entity\Task; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class TaskDataTest extends KernelTestCase +{ + public function testPublicVariables() + { + $this->assertClassHasAttribute('task', TaskData::class); + $this->assertClassHasAttribute('dueDate', TaskData::class); + $this->assertClassNotHasAttribute('id', TaskData::class); + + $this->assertFalse(method_exists(TaskData::class, 'setTask')); + $this->assertFalse(method_exists(TaskData::class, 'getTask')); + + $this->assertFalse(method_exists(TaskData::class, 'setDueDate')); + $this->assertFalse(method_exists(TaskData::class, 'getDueDate')); + } + + public function testMutator() + { + $taskEntity = new Task(); + $taskEntity->setTask('Acme'); + $taskEntity->setDueDate(new \DateTime('2018-01-29 01:30')); + + $taskData = new TaskData($taskEntity); + + $this->assertEquals($taskEntity->getTask(), $taskData->task); + $this->assertEquals($taskEntity->getDueDate(), $taskData->dueDate); + + $taskData->task = 'Foo'; + + $this->assertNotEquals($taskEntity->getTask(), $taskData->task); + + $taskEntity->updateFromTaskData($taskData); + + $this->assertEquals($taskEntity->getTask(), $taskData->task); + $this->assertEquals($taskEntity->getDueDate(), $taskData->dueDate); + } +} diff --git a/tests/fixtures/MakeDtoValidatorYamlXml/config/validator/validation.xml b/tests/fixtures/MakeDtoValidatorYamlXml/config/validator/validation.xml new file mode 100644 index 000000000..3273ee8c0 --- /dev/null +++ b/tests/fixtures/MakeDtoValidatorYamlXml/config/validator/validation.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/tests/fixtures/MakeDtoValidatorYamlXml/config/validator/validation.yaml b/tests/fixtures/MakeDtoValidatorYamlXml/config/validator/validation.yaml new file mode 100644 index 000000000..356348e57 --- /dev/null +++ b/tests/fixtures/MakeDtoValidatorYamlXml/config/validator/validation.yaml @@ -0,0 +1,4 @@ +App\Entity\Task: + properties: + dueDate: + - Type: DateTime diff --git a/tests/fixtures/MakeDtoValidatorYamlXml/src/Entity/Task.php b/tests/fixtures/MakeDtoValidatorYamlXml/src/Entity/Task.php new file mode 100644 index 000000000..8b7a20595 --- /dev/null +++ b/tests/fixtures/MakeDtoValidatorYamlXml/src/Entity/Task.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * @ORM\Entity() + */ +class Task +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255, nullable=true) + * @Assert\Type("string") + */ + private $task; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $dueDate; + + public function getId() + { + return $this->id; + } + + /** + * Get the value of task. + */ + public function getTask() + { + return $this->task; + } + + /** + * Set the value of task. + * + * @return self + */ + public function setTask($task) + { + $this->task = $task; + + return $this; + } + + /** + * Get the value of dueDate. + */ + public function getDueDate() + { + return $this->dueDate; + } + + /** + * Set the value of dueDate. + * + * @return self + */ + public function setDueDate($dueDate) + { + $this->dueDate = $dueDate; + + return $this; + } +} diff --git a/tests/fixtures/MakeDtoValidatorYamlXml/tests/TaskDataTest.php b/tests/fixtures/MakeDtoValidatorYamlXml/tests/TaskDataTest.php new file mode 100644 index 000000000..102ee1a71 --- /dev/null +++ b/tests/fixtures/MakeDtoValidatorYamlXml/tests/TaskDataTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests; + +use App\Dto\TaskData; +use App\Entity\Task; +use Doctrine\Common\Annotations\AnnotationReader; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Validation; + +class TaskDataTest extends KernelTestCase +{ + public function testGeneratedDto() + { + // simply test if DTO validates with only the field with annotation being valid. + $taskData = new TaskData(); + + // valid + $taskData->task = 'foobar'; + + // invalid, but only with constraint from validation.yaml + $taskData->dueDate = 'foo'; + + // create validator + $validator = Validation::createValidatorBuilder() + ->enableAnnotationMapping() + ->getValidator(); + + $this->assertEmpty($validator->validate($taskData)); + + // invalid + $taskData->task = 123; + + $this->assertCount(1, $validator->validate($taskData)); + } + + public function testAnnotations() + { + // "task" property may only have Type constraint from annotation + $annotationReader = new AnnotationReader(); + $reflectionProperty = new \ReflectionProperty(TaskData::class, 'task'); + $propertyAnnotations = $annotationReader->getPropertyAnnotations($reflectionProperty); + $this->assertCount(1, $propertyAnnotations); + $this->assertContainsOnlyInstancesOf(Type::class, $propertyAnnotations); + + // dueDate may not have an annotation + $reflectionProperty = new \ReflectionProperty(TaskData::class, 'dueDate'); + $propertyAnnotations = $annotationReader->getPropertyAnnotations($reflectionProperty); + $this->assertCount(0, $propertyAnnotations); + } +}