Skip to content

Commit

Permalink
DTO generator (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
loicsapone committed Oct 17, 2023
1 parent f89fc0d commit 558be74
Show file tree
Hide file tree
Showing 10 changed files with 321 additions and 25 deletions.
1 change: 1 addition & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
->ignoreDotFiles(false)
->ignoreVCSIgnored(true)
->in(__DIR__)
->exclude('tests/fixtures')
;

$config = new PhpCsFixer\Config();
Expand Down
21 changes: 21 additions & 0 deletions bin/dataimporter
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

use IQ2i\DataImporter\Command\GenerateDtoCommand;
use Symfony\Component\Console\Application;

if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}

require __DIR__.'/../vendor/autoload.php';

$application = new Application('DataImporter');
$command = new GenerateDtoCommand();

$application->add($command);

$application->setDefaultCommand($command->getName(), true);
$application->run();
9 changes: 7 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"symfony/filesystem": "^6.1",
"symfony/property-access": "^6.1",
"symfony/serializer": "^6.1",
"halaxa/json-machine": "^1.1"
"halaxa/json-machine": "^1.1",
"nette/php-generator": "^4.0",
"symfony/string": "^6.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.13",
Expand All @@ -39,5 +41,8 @@
},
"autoload-dev": {
"psr-4": { "IQ2i\\DataImporter\\Tests\\": "tests" }
}
},
"bin": [
"bin/dataimporter"
]
}
1 change: 1 addition & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
]);

$rectorConfig->skip([
__DIR__.'/tests/fixtures/*',
NewInInitializerRector::class,
FlipTypeControlToUseExclusiveTypeRector::class,
]);
Expand Down
6 changes: 6 additions & 0 deletions src/Bundle/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use IQ2i\DataImporter\Bundle\Messenger\MessageHandler;
use IQ2i\DataImporter\Command\GenerateDtoCommand;

return static function (ContainerConfigurator $container) {
$container->services()
->set('iq2i_data_importer.generate_dto_command', GenerateDtoCommand::class)
->arg('$defaultPath', '%kernel.project_dir%')
->arg('$defaultNamespace', 'App\\Dto')
->tag('console.command', ['command' => 'iq2i:data-importer:generate-dto'])

->set('iq2i_data_importer.messenger.message_handler', MessageHandler::class)
->tag('messenger.message_handler')
;
Expand Down
136 changes: 136 additions & 0 deletions src/Command/GenerateDtoCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

/*
* This file is part of the DataImporter package.
*
* (c) Loïc Sapone <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace IQ2i\DataImporter\Command;

use IQ2i\DataImporter\Dto\Generator;
use IQ2i\DataImporter\Dto\TypeDetector;
use IQ2i\DataImporter\Reader\CsvReader;
use IQ2i\DataImporter\Reader\ReaderInterface;
use IQ2i\DataImporter\Reader\XmlReader;
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\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;

use function Symfony\Component\String\u;

class GenerateDtoCommand extends Command
{
public function __construct(
private ?string $defaultPath = null,
private ?string $defaultNamespace = null,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->setName('generate')
->setDescription('Generate DTO from file to import.')
->addArgument('file', InputArgument::REQUIRED, 'The file from which the DTO should be generated.')
->addOption('length', null, InputOption::VALUE_OPTIONAL, 'Number of lines to analyze.', 10)
->addOption('path', null, InputOption::VALUE_REQUIRED, 'Customize the path for generated DTOs')
->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Customize the namespace for generated DTOs')
;
}

protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);

if (null === $this->defaultPath || $input->hasOption('path')) {
$this->defaultPath = $input->getOption('path') ?? $io->ask("Specify the DTO's path");
}

$this->defaultPath = \rtrim((string) $this->defaultPath, '/');

if (null === $this->defaultNamespace || $input->hasOption('namespace')) {
$this->defaultNamespace = $input->getOption('namespace') ?? $io->ask("Specify the DTO's namespace");
}
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$filesystem = new Filesystem();
$io = new SymfonyStyle($input, $output);

$file = $input->getArgument('file');
if (!\file_exists($file)) {
throw new \InvalidArgumentException(\sprintf('File "%s" does not exists.', $file));
}

$dtoClass = $io->ask('Class name of the entity to create or update');
$dtoFilename = $this->defaultPath.'/'.$dtoClass.'.php';
if ($filesystem->exists($dtoFilename) && !$io->confirm(\sprintf('File %s already exists. Do you want to override it?', $dtoFilename))) {
return Command::SUCCESS;
}

$readerClass = 'IQ2i\\DataImporter\\Reader\\'.$io->choice(
'Which reader do you want to use?',
['CsvReader', 'JsonReader', 'XmlReader']
);

$context = match ($readerClass) {
CsvReader::class => [
CsvReader::CONTEXT_DELIMITER => $io->ask('Specify the delimiter', ','),
CsvReader::CONTEXT_ENCLOSURE => $io->ask('Specify the enclosure', '"'),
CsvReader::CONTEXT_ESCAPE_CHAR => $io->ask('Specify the escape character', ''),
],
XmlReader::class => [
XmlReader::CONTEXT_XPATH => $io->ask('Specify the xpath', ''),
],
default => [],
};

/** @var ReaderInterface $reader */
$reader = new $readerClass($file, null, $context);

$properties = [];
foreach ($reader->current() as $key => $value) {
$name = u($key)->camel()->toString();
$properties[$key] = [
'name' => $name,
'serialized_name' => $name !== $key ? $key : null,
'types' => [TypeDetector::findType($value)],
];
}

for ($i = 0; $i < $input->getOption('length'); ++$i) {
foreach ($reader->current() as $key => $value) {
$properties[$key]['types'][] = TypeDetector::findType($value);
}

$reader->next();
}

foreach ($properties as &$property) {
$property['type'] = TypeDetector::resolve($property['types']);
unset($property['types']);
}

$generatedDto = (new Generator())->generate($dtoClass, $properties, $this->defaultNamespace);

if (!$filesystem->exists($this->defaultPath)) {
$filesystem->mkdir($this->defaultPath);
}

$filesystem->dumpFile($dtoFilename, $generatedDto);

return Command::SUCCESS;
}
}
70 changes: 70 additions & 0 deletions src/Dto/Generator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

/*
* This file is part of the DataImporter package.
*
* (c) Loïc Sapone <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace IQ2i\DataImporter\Dto;

use Nette\PhpGenerator\PhpFile;
use Nette\PhpGenerator\PsrPrinter;

use function Symfony\Component\String\u;

class Generator
{
/**
* @var string
*/
private const NAMESPACE = 'App\Dto';

public function generate(string $class, array $columns, string $namespace = null): string
{
$file = new PhpFile();
$file->setStrictTypes();

$namespace = $file->addNamespace($namespace ?? self::NAMESPACE);
$class = $namespace->addClass($class);

foreach ($columns as $column) {
$property = $class->addProperty($column['name'])
->setPrivate()
->setType('?'.$column['type'])
->setInitialized();

if (null !== $column['serialized_name']) {
$namespace->addUse(\Symfony\Component\Serializer\Annotation\SerializedName::class);

$property->addAttribute(\Symfony\Component\Serializer\Annotation\SerializedName::class, [$column['serialized_name']]);
}
}

foreach ($columns as $column) {
$class->addMethod('get'.u($column['name'])->camel()->title())
->setPublic()
->setReturnType('?'.$column['type'])
->addBody('return $this->?;', [$column['name']]);

$setter = $class->addMethod('set'.u($column['name'])->camel()->title())
->setPublic()
->setReturnType('self')
->addBody('$this->? = $?;', [$column['name'], $column['name']])
->addBody('')
->addBody('return $this;');

$setter->addParameter($column['name'])
->setType('?'.$column['type']);
}

$printer = new PsrPrinter();

return $printer->printFile($file);
}
}
35 changes: 35 additions & 0 deletions src/Dto/TypeDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

/*
* This file is part of the DataImporter package.
*
* (c) Loïc Sapone <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace IQ2i\DataImporter\Dto;

class TypeDetector
{
public static function findType(string $value): string
{
if (\is_numeric($value) && \str_contains($value, '.')) {
return 'float';
} elseif (\is_numeric($value) && !\in_array($value, ['0', '1'])) {
return 'int';
} elseif (\in_array($value, ['0', '1', 'true', 'false'])) {
return 'bool';
} else {
return 'string';
}
}

public static function resolve(array $types): string
{
return 1 === \count(\array_unique($types, \SORT_REGULAR)) ? $types[0] : 'string';
}
}
34 changes: 34 additions & 0 deletions tests/Dto/GeneratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

/*
* This file is part of the DataImporter package.
*
* (c) Loïc Sapone <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace IQ2i\DataImporter\Tests\Dto;

use IQ2i\DataImporter\Dto\Generator;
use PHPUnit\Framework\TestCase;

class GeneratorTest extends TestCase
{
public function testGenerate()
{
$generator = new Generator();
$generatedDto = $generator->generate('Book', [
['name' => 'author', 'serialized_name' => null, 'type' => 'string'],
['name' => 'title', 'serialized_name' => null, 'type' => 'string'],
['name' => 'genre', 'serialized_name' => null, 'type' => 'string'],
['name' => 'price', 'serialized_name' => null, 'type' => 'float'],
['name' => 'description', 'serialized_name' => null, 'type' => 'string'],
], 'IQ2i\DataImporter\Tests\fixtures\Dto');

$this->assertStringEqualsFile(__DIR__.'/../fixtures/Dto/Book.php', $generatedDto);
}
}
Loading

0 comments on commit 558be74

Please sign in to comment.