Completed
Pull Request — master (#433)
by Paul
06:40
created

WidgetBundle/Command/CreateWidgetCommand.php (1 issue)

Labels
Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace Victoire\Bundle\WidgetBundle\Command;
4
5
use Doctrine\DBAL\Types\Type;
6
use Sensio\Bundle\GeneratorBundle\Command\GenerateBundleCommand;
7
use Sensio\Bundle\GeneratorBundle\Command\Helper\QuestionHelper;
8
use Sensio\Bundle\GeneratorBundle\Command\Validators;
9
use Sensio\Bundle\GeneratorBundle\Generator\DoctrineEntityGenerator;
10
use Symfony\Component\Console\Input\InputInterface;
11
use Symfony\Component\Console\Input\InputOption;
12
use Symfony\Component\Console\Output\OutputInterface;
13
use Symfony\Component\Console\Question\ConfirmationQuestion;
14
use Symfony\Component\Console\Question\Question;
15
use Symfony\Component\DependencyInjection\Container;
16
use Symfony\Component\HttpKernel\Bundle\BundleInterface;
17
use Victoire\Bundle\WidgetBundle\Generator\WidgetGenerator;
18
19
/**
20
 * Create a new Widget for VictoireCMS.
21
 */
22
class CreateWidgetCommand extends GenerateBundleCommand
23
{
24
    protected $skeletonDirs;
25
26
    /**
27
     * {@inheritdoc}
28
     */
29
    public function configure()
30
    {
31
        parent::configure();
32
33
        $this
34
            ->setName('victoire:generate:widget')
35
            ->setDefinition([
36
                new InputOption('namespace', '', InputOption::VALUE_REQUIRED, 'The namespace of the widget bundle to create'),
37
                new InputOption('dir', '', InputOption::VALUE_REQUIRED, 'The directory where to create the bundle'),
38
                new InputOption('bundle-name', '', InputOption::VALUE_REQUIRED, 'The optional bundle name'),
39
                new InputOption('orgname', '', InputOption::VALUE_REQUIRED, 'Your organisation name'),
40
                new InputOption('widget-name', '', InputOption::VALUE_REQUIRED, 'The widget name'),
41
                new InputOption('format', '', InputOption::VALUE_REQUIRED, 'Use the format for configuration files (php, xml, yml, or annotation)'),
42
                new InputOption('structure', '', InputOption::VALUE_NONE, 'Whether to generate the whole directory structure'),
43
                new InputOption('fields', '', InputOption::VALUE_REQUIRED, 'The fields to create with the new entity'),
44
                new InputOption('entity', '', InputOption::VALUE_REQUIRED, 'The entity class name to initialize (shortcut notation)'),
45
                new InputOption('parent', '', InputOption::VALUE_REQUIRED, 'The widget this widget will extends'),
46
                new InputOption('packagist-parent-name', '', InputOption::VALUE_REQUIRED, 'The packagist name of the widget you want to extends'),
47
                new InputOption('content-resolver', '', InputOption::VALUE_NONE, 'Whether to generate a blank ContentResolver to customize widget rendering logic'),
48
                new InputOption('cache', '', InputOption::VALUE_NONE, 'Use redis cache to store widgets until next modification'),
49
            ])
50
            ->setDescription('Generate a new widget')
51
            ->setHelp(<<<'EOT'
52
The <info>victoire:generate:widget</info> command helps you to generate new widgets.
53
54
By default, the command interacts with the developer to tweak the generation.
55
Any passed option will be used as a default value for the interaction
56
(<comment>--widget-name</comment> is the only one needed if you follow the
57
conventions):
58
59
<info>php app/console victoire:generate:widget --widget-name=myAwesomeWidget</info>
60
61
If you want to disable any user interaction, use <comment>--no-interaction</comment> but don't forget to pass all needed options:
62
63
Love you guys, you're awesome xxx
64
EOT
65
            );
66
    }
67
68
    /**
69
     * Take arguments and options defined in $this->interact() and generate a new Widget.
70
     *
71
     * @param InputInterface  $input
72
     * @param OutputInterface $output
73
     *
74
     * @see Command
75
     *
76
     * @throws \InvalidArgumentException When namespace doesn't end with Bundle
77
     * @throws \RuntimeException         When bundle can't be executed
78
     *
79
     * @return int|null
80
     */
81
    protected function execute(InputInterface $input, OutputInterface $output)
82
    {
83
        $questionHelper = $this->getQuestionHelper();
84
85
        if ($input->isInteractive()) {
86
            $question = new ConfirmationQuestion($questionHelper->getQuestion('Do you confirm generation', 'yes', '?'), true);
87
            if (!$questionHelper->ask($input, $output, $question)) {
88
                $output->writeln('<error>Command aborted</error>');
89
90
                return 1;
91
            }
92
        }
93
94
        foreach (['namespace', 'dir'] as $option) {
95
            if (null === $input->getOption($option)) {
96
                throw new \RuntimeException(sprintf('The "%s" option must be provided.', $option));
97
            }
98
        }
99
100
        $namespace = Validators::validateBundleNamespace($input->getOption('namespace'));
101
102
        if (!$bundle = $input->getOption('bundle-name')) {
103
            $bundle = strtr($namespace, ['\\' => '']);
104
        }
105
106
        $orgname = $input->getOption('orgname');
107
108
        if (null === $input->getOption('orgname')) {
109
            $orgname = $input->setOption('orgname', 'friendsofvictoire');
110
        }
111
112
        $parent = $input->getOption('parent');
113
114
        if (null === $input->getOption('parent')) {
115
            $parent = $input->setOption('parent', null);
116
        }
117
118
        $packagistParentName = $input->getOption('packagist-parent-name');
119
120
        if (null === $input->getOption('packagist-parent-name')) {
121
            $packagistParentName = $input->setOption('packagist-parent-name', null);
122
        }
123
124
        $bundle = Validators::validateBundleName($bundle);
125
        $dir = Validators::validateTargetDir($input->getOption('dir'), $bundle, $namespace);
126
127
        if (null === $input->getOption('format')) {
128
            $input->setOption('format', 'annotation');
129
        }
130
131
        $format = Validators::validateFormat($input->getOption('format'));
132
        $structure = $input->getOption('structure');
133
134
        $contentResolver = $input->getOption('content-resolver');
135
        $cache = $input->getOption('cache');
136
137
        $questionHelper->writeSection($output, 'Bundle generation');
138
139
        if (!$this->getContainer()->get('filesystem')->isAbsolutePath($dir)) {
140
            $dir = getcwd().'/'.$dir;
141
        }
142
143
        $fields = $this->parseFields($input->getOption('fields'));
144
145
        $parentContentResolver = $this->getContainer()->has('victoire_core.widget_'.strtolower($parent).'_content_resolver');
146
147
        $generator = $this->getGenerator();
148
        $generator->generate($namespace, $bundle, $dir, $format, $structure, $fields, $parent, $packagistParentName, $contentResolver, $parentContentResolver, $orgname, $cache);
149
150
        $output->writeln('Generating the bundle code: <info>OK</info>');
151
152
        $errors = [];
153
        $runner = $questionHelper->getRunner($output, $errors);
154
155
        // check that the namespace is already autoloaded
156
        $runner($this->checkAutoloader($output, $namespace, $bundle, $dir));
157
158
        // register the bundle in the Kernel class
159
        $runner($this->updateKernel($questionHelper, $input, $output, $this->getContainer()->get('kernel'), $namespace, $bundle));
160
161
        $questionHelper->writeGeneratorSummary($output, $errors);
162
    }
163
164
    /**
165
     * get a generator for given widget and type, and attach it skeleton dirs.
166
     *
167
     * @return $generator
168
     */
169 View Code Duplication
    protected function getEntityGenerator()
170
    {
171
        $dirs[] = $this->getContainer()->get('file_locator')->locate('@VictoireWidgetBundle/Resources/skeleton/');
172
        $dirs[] = $this->getContainer()->get('file_locator')->locate('@VictoireWidgetBundle/Resources/');
173
174
        $generator = $this->createEntityGenerator();
175
176
        $this->skeletonDirs = array_merge($this->getSkeletonDirs(), $dirs);
177
        $generator->setSkeletonDirs($this->skeletonDirs);
178
        $this->setGenerator($generator);
179
180
        return $generator;
181
    }
182
183
    /**
184
     * get a generator for given widget and type, and attach it skeleton dirs.
185
     *
186
     * @return $generator
187
     */
188 View Code Duplication
    protected function getGenerator(BundleInterface $bundle = null)
189
    {
190
        $dirs[] = $this->getContainer()->get('file_locator')->locate('@VictoireWidgetBundle/Resources/skeleton/');
191
        $dirs[] = $this->getContainer()->get('file_locator')->locate('@VictoireWidgetBundle/Resources/');
192
193
        $generator = $this->createWidgetGenerator();
194
195
        $this->skeletonDirs = array_merge($this->getSkeletonDirs(), $dirs);
196
        $generator->setSkeletonDirs($this->skeletonDirs);
197
        $this->setGenerator($generator);
198
199
        return $generator;
200
    }
201
202
    /**
203
     * Collect options and arguments.
204
     *
205
     * @param InputInterface  $input
206
     * @param OutputInterface $output
207
     *
208
     * @return void
209
     */
210
    protected function interact(InputInterface $input, OutputInterface $output)
211
    {
212
        $questionHelper = $this->getQuestionHelper();
213
        $questionHelper->writeSection($output, 'Welcome to the Victoire widget bundle generator');
214
215
        ///////////////////////
216
        //                   //
217
        //   Create Bundle   //
218
        //                   //
219
        ///////////////////////
220
221
        // namespace
222
        $namespace = null;
223
        try {
224
            $namespace = $input->getOption('namespace') ? Validators::validateBundleNamespace($input->getOption('namespace')) : null;
225
        } catch (\Exception $error) {
226
            $output->writeln($questionHelper->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error'));
227
        }
228
229
        if (null === $namespace) {
230
            $output->writeln([
231
                '',
232
                'Your application code must be written in <comment>widget bundles</comment>. This command helps',
233
                'you generate them easily.',
234
                '',
235
                'Each widget is hosted under a namespace (like <comment>Victoire/Widget/YourAwesomeWidgetNameBundle</comment>).',
236
                '',
237
                'If you want for example a BlogWidget, the Widget Name should be Blog',
238
            ]);
239
240
            $question = new Question($questionHelper->getQuestion('Widget name', $input->getOption('bundle-name')));
241
            $question->setValidator(function ($answer) {
242
                return self::validateWidgetName($answer, false);
243
            });
244
245
            $name = $questionHelper->ask(
246
                $input,
247
                $output,
248
                $question
249
            );
250
251
            $bundle = 'VictoireWidget'.$name.'Bundle';
252
            $input->setOption('bundle-name', $bundle);
253
            $namespace = 'Victoire\\Widget\\'.$name.'Bundle';
254
            $input->setOption('namespace', $namespace);
255
        }
256
257
        $orgname = $input->getOption('orgname');
258
259
        if (null === $orgname) {
260
            $output->writeln([
261
                '',
262
                'A composer.json file will be generated, we need to know under which organisation you will publish the widget',
263
                '',
264
                'The default organisation will be friendsofvictoire',
265
            ]);
266
            $question = new Question($questionHelper->getQuestion('Under which organisation do you want to publish your widget ?', 'friendsofvictoire'), 'friendsofvictoire');
267
268
            $orgname = $questionHelper->ask($input, $output, $question);
269
        }
270
271
        $input->setOption('orgname', $orgname);
272
273
        $parent = $input->getOption('parent');
274
275
        $question = new ConfirmationQuestion($questionHelper->getQuestion('Does your widget extends another widget ?', 'no', '?'), false);
276
277
        if (null === $parent && $questionHelper->ask($input, $output, $question)) {
278
            $output->writeln([
279
                '',
280
                'A widget can extends another to reproduce it\'s behavior',
281
                '',
282
                'If you wabt to do so, please give the name of the widget to extend',
283
                '',
284
                'If you want to extends the TestWidget, the widget name should be Test',
285
            ]);
286
287
            $question = new Question($questionHelper->getQuestion('Parent widget name', false));
288
            $question->setValidator(function ($answer) {
289
                return self::validateWidgetName($answer, false);
290
            });
291
            $parent = $questionHelper->ask($input, $output, $question);
292
293
            $input->setOption('parent', $parent);
294
295
            $packagistParentName = 'friendsofvictoire/'.strtolower($parent).'-widget';
296
            $question = new Question($questionHelper->getQuestion('Parent widget packagist name', $packagistParentName));
297
298
            $parent = $questionHelper->ask($input, $output, $question);
299
300
            $input->setOption('packagist-parent-name', $packagistParentName);
301
        }
302
303
        $dir = dirname($this->getContainer()->getParameter('kernel.root_dir')).'/src';
304
305
        $output->writeln([
306
            '',
307
            'The bundle can be generated anywhere. The suggested default directory uses',
308
            'the standard conventions.',
309
            '',
310
        ]);
311
312
        $question = new Question($questionHelper->getQuestion('Target directory', $dir), $dir);
313
        $question->setValidator(function ($dir) use ($bundle, $namespace) {
314
            return Validators::validateTargetDir($dir, $bundle, $namespace);
315
        });
316
        $dir = $questionHelper->ask($input, $output, $question);
317
        $input->setOption('dir', $dir);
318
319
        // format
320
        $format = null;
321
        try {
322
            $format = $input->getOption('format') ? Validators::validateFormat($input->getOption('format')) : null;
323
        } catch (\Exception $error) {
324
            $output->writeln($questionHelper->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error'));
325
        }
326
327
        if (null === $format) {
328
            $output->writeln([
329
                '',
330
                'Determine the format to use for the generated configuration.',
331
                '',
332
            ]);
333
334
            $question = new Question($questionHelper->getQuestion('Configuration format (yml, xml, php, or annotation)', 'annotation'), 'annotation');
335
            $question->setValidator(
336
                ['Sensio\Bundle\GeneratorBundle\Command\Validators', 'validateFormat']
337
            );
338
            $format = $questionHelper->ask($input, $output, $question);
339
            $input->setOption('format', $format);
340
        }
341
342
        $input->setOption('structure', false);
343
344
        $contentResolver = $input->getOption('content-resolver');
345
346
        $question = new ConfirmationQuestion($questionHelper->getQuestion('Do you want to customize widget rendering logic ?', 'no', '?'), false);
347
        if (!$contentResolver && $questionHelper->ask($input, $output, $question)) {
348
            $contentResolver = true;
349
        }
350
        $input->setOption('content-resolver', $contentResolver);
351
352
        ///////////////////////
353
        //                   //
354
        //   Create Entity   //
355
        //                   //
356
        ///////////////////////
357
358
        $input->setOption('fields', $this->addFields($input, $output, $questionHelper));
359
        $entity = 'Widget'.$name;
360
        $input->setOption('entity', $bundle.':'.$entity);
361
362
        $cache = $input->getOption('cache');
363
        $question = new ConfirmationQuestion($questionHelper->getQuestion('Do you want use cache for this widget ?', 'no', '?'), false);
364
        if (null !== $cache) {
365
            $cache = $questionHelper->ask($input, $output, $question);
366
        }
367
        $input->setOption('cache', $cache);
368
369
        // summary
370
        $output->writeln([
371
            '',
372
            $this->getHelper('formatter')->formatBlock('Summary before generation', 'bg=blue;fg=white', true),
0 ignored issues
show
It seems like you code against a concrete implementation and not the interface Symfony\Component\Console\Helper\HelperInterface as the method formatBlock() does only exist in the following implementations of said interface: Symfony\Component\Console\Helper\FormatterHelper.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
373
            '',
374
            sprintf("You are going to generate a \"<info>%s\\%s</info>\" widget bundle\nin \"<info>%s</info>\" using the \"<info>%s</info>\" format.", $namespace, $bundle, $dir, $format),
375
            '',
376
        ]);
377
    }
378
379
    /**
380
     * Check that provided widget name is correct.
381
     *
382
     * @param string $widget
383
     *
384
     * @return string $widget
385
     */
386
    public static function validateWidgetName($widget)
387
    {
388
        if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $widget)) {
389
            throw new \InvalidArgumentException('The widget name contains invalid characters.');
390
        }
391
392
        if (!preg_match('/^([A-Z][a-z]+)+$/', $widget)) {
393
            throw new \InvalidArgumentException('The widget name must be PascalCased.');
394
        }
395
396
        return $widget;
397
    }
398
399
    /**
400
     * Instanciate a new WidgetGenerator.
401
     *
402
     * @return $generator
403
     */
404
    protected function createWidgetGenerator()
405
    {
406
        $generator = new WidgetGenerator($this->getContainer()->get('filesystem'));
407
        $generator->setTemplating($this->getContainer()->get('twig'));
408
409
        return $generator;
410
    }
411
412
    /**
413
     * Instanciate a new Entity generator.
414
     *
415
     * @return $generator
416
     */
417
    protected function createEntityGenerator()
418
    {
419
        return new DoctrineEntityGenerator($this->getContainer()->get('filesystem'), $this->getContainer()->get('doctrine'));
420
    }
421
422
    /**
423
     * transform console's output string fields into an array of fields.
424
     *
425
     * @param string $input
426
     *
427
     * @return array $fields
428
     */
429
    private function parseFields($input)
430
    {
431
        if (is_array($input)) {
432
            return $input;
433
        }
434
435
        $fields = [];
436
        foreach (explode(' ', $input) as $value) {
437
            $elements = explode(':', $value);
438
            $name = $elements[0];
439
            if (strlen($name)) {
440
                $type = isset($elements[1]) ? $elements[1] : 'string';
441
                preg_match_all('/(.*)\((.*)\)/', $type, $matches);
442
                $type = isset($matches[1][0]) ? $matches[1][0] : $type;
443
                $length = isset($matches[2][0]) ? $matches[2][0] : null;
444
445
                $fields[$name] = ['fieldName' => $name, 'type' => $type, 'length' => $length];
446
            }
447
        }
448
449
        return $fields;
450
    }
451
452
    /**
453
     * Interactively ask user to add field to his new Entity.
454
     *
455
     * @param InputInterface  $input
456
     * @param OutputInterface $output
457
     * @param QuestionHelper  $questionHelper
458
     *
459
     * @return $fields
460
     */
461
    private function addFields(InputInterface $input, OutputInterface $output, QuestionHelper $questionHelper)
462
    {
463
        $fields = $this->parseFields($input->getOption('fields'));
464
        $output->writeln([
465
            '',
466
            'Instead of starting with a blank entity, you can add some fields now.',
467
            'Note that the primary key will be added automatically (named <comment>id</comment>).',
468
            '',
469
        ]);
470
        $output->write('<info>Available types:</info> ');
471
472
        $types = array_keys(Type::getTypesMap());
473
        $count = 20;
474
        foreach ($types as $i => $type) {
475
            if ($count > 50) {
476
                $count = 0;
477
                $output->writeln('');
478
            }
479
            $count += strlen($type);
480
            $output->write(sprintf('<comment>%s</comment>', $type));
481
            if (count($types) != $i + 1) {
482
                $output->write(', ');
483
            } else {
484
                $output->write('.');
485
            }
486
        }
487
        $output->writeln('');
488
489
        $fieldValidator = function ($type) use ($types) {
490
            if (!in_array($type, $types)) {
491
                throw new \InvalidArgumentException(sprintf('Invalid type "%s".', $type));
492
            }
493
494
            return $type;
495
        };
496
497
        $lengthValidator = function ($length) {
498
            if (!$length) {
499
                return $length;
500
            }
501
502
            $result = filter_var($length, FILTER_VALIDATE_INT, [
503
                'options' => ['min_range' => 1],
504
            ]);
505
506
            if (false === $result) {
507
                throw new \InvalidArgumentException(sprintf('Invalid length "%s".', $length));
508
            }
509
510
            return $length;
511
        };
512
513
        while (true) {
514
            $output->writeln('');
515
            $generator = $this->getEntityGenerator();
516
517
            $question = new Question($questionHelper->getQuestion('New field name (press <return> to stop adding fields)', null));
518
            $question->setValidator(
519
                function ($name) use ($fields, $generator) {
520
                    if (isset($fields[$name]) || 'id' == $name) {
521
                        throw new \InvalidArgumentException(sprintf('Field "%s" is already defined.', $name));
522
                    }
523
524
                    // check reserved words by database
525
                    if ($generator->isReservedKeyword($name)) {
526
                        throw new \InvalidArgumentException(sprintf('Name "%s" is a reserved word.', $name));
527
                    }
528
                    // check reserved words by victoire
529
                    if ($this->isReservedKeyword($name)) {
530
                        throw new \InvalidArgumentException(sprintf('Name "%s" is a Victoire reserved word.', $name));
531
                    }
532
533
                    return $name;
534
                }
535
            );
536
537
            $columnName = $questionHelper->ask($input, $output, $question);
538
            if (!$columnName) {
539
                break;
540
            }
541
542
            $defaultType = 'string';
543
544
            // try to guess the type by the column name prefix/suffix
545
            if (substr($columnName, -3) == '_at') {
546
                $defaultType = 'datetime';
547
            } elseif (substr($columnName, -3) == '_id') {
548
                $defaultType = 'integer';
549
            } elseif (substr($columnName, 0, 3) == 'is_') {
550
                $defaultType = 'boolean';
551
            } elseif (substr($columnName, 0, 4) == 'has_') {
552
                $defaultType = 'boolean';
553
            }
554
555
            $question = new Question($questionHelper->getQuestion('Field type', $defaultType), $defaultType);
556
            $question->setValidator($fieldValidator);
557
            $question->setAutocompleterValues($types);
558
            $type = $questionHelper->ask($input, $output, $question);
559
560
            $data = ['columnName' => $columnName, 'fieldName' => lcfirst(Container::camelize($columnName)), 'type' => $type];
561
562
            if ($type == 'string') {
563
                $question = new Question($questionHelper->getQuestion('Field length', 255), 255);
564
                $question->setValidator($lengthValidator);
565
                $data['length'] = $questionHelper->ask($input, $output, $question);
566
            }
567
568
            $fields[$columnName] = $data;
569
        }
570
571
        return $fields;
572
    }
573
574
    /**
575
     * Validate Entity short namepace.
576
     *
577
     * @param string $shortcut
578
     *
579
     * @return $shortcut
580
     */
581
    protected function parseShortcutNotation($shortcut)
582
    {
583
        $entity = str_replace('/', '\\', $shortcut);
584
585
        if (false === $pos = strpos($entity, ':')) {
586
            throw new \InvalidArgumentException(sprintf('The entity name must contain a : ("%s" given, expecting something like AcmeBlogBundle:Blog/Post)', $entity));
587
        }
588
589
        return [substr($entity, 0, $pos), substr($entity, $pos + 1)];
590
    }
591
592
    protected function isReservedKeyword($keyword)
593
    {
594
        return in_array($keyword, ['widget']);
595
    }
596
}
597