<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Form\Type;

use ArrayObject;
use EasyCorp\Bundle\EasyAdminBundle\Configuration\ConfigManager;
use EasyCorp\Bundle\EasyAdminBundle\Form\EventListener\EasyAdminTabSubscriber;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Configurator\TypeConfiguratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Form\Util\FormTypeHelper;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Custom form type that deals with some of the logic used to render the
 * forms used to create and edit EasyAdmin entities.
 *
 * @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
 */
class EasyAdminFormType extends AbstractType
{
    /** @var ConfigManager */
    private $configManager;

    /** @var TypeConfiguratorInterface[] */
    private $configurators;

    /**
     * @param ConfigManager               $configManager
     * @param TypeConfiguratorInterface[] $configurators
     */
    public function __construct(ConfigManager $configManager, array $configurators = [])
    {
        $this->configManager = $configManager;
        $this->configurators = $configurators;
    }

    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $entity = $options['entity'];
        $view = $options['view'];
        $entityConfig = $this->configManager->getEntityConfig($entity);
        $entityProperties = $entityConfig[$view]['fields'] ?? [];
        $formTabs = [];
        $currentFormTab = null;
        $formGroups = [];
        $currentFormGroup = null;

        foreach ($entityProperties as $name => $metadata) {
            $formFieldOptions = $metadata['type_options'];

            // Configure options using the list of registered type configurators:
            foreach ($this->configurators as $configurator) {
                if ($configurator->supports($metadata['fieldType'], $formFieldOptions, $metadata)) {
                    $formFieldOptions = $configurator->configure($name, $formFieldOptions, $metadata, $builder);
                }
            }

            $formFieldType = FormTypeHelper::getTypeClass($metadata['fieldType']);

            // if the form field is a special 'group' design element, don't add it
            // to the form. Instead, consider it the current form group (this is
            // applied to the form fields defined after it) and store its details
            // in a property to get them in form template
            if (\in_array($formFieldType, ['easyadmin_group', EasyAdminGroupType::class])) {
                $metadata['form_tab'] = $currentFormTab ?: null;
                $currentFormGroup = $metadata['fieldName'];
                $formGroups[$currentFormGroup] = $metadata;

                continue;
            }

            // if the form field is a special 'tab' design element, don't add it
            // to the form. Instead, consider it the current form group (this is
            // applied to the form fields defined after it) and store its details
            // in a property to get them in form template
            if (\in_array($formFieldType, ['easyadmin_tab', EasyAdminTabType::class])) {
                // The first tab should be marked as active by default
                $metadata['active'] = 0 === \count($formTabs);
                $metadata['errors'] = 0;
                $currentFormTab = $metadata['fieldName'];

                // plain arrays are not enough for tabs because they are modified in the
                // lifecycle of a form (e.g. add info about form errors). Use an ArrayObject instead.
                $formTabs[$currentFormTab] = new ArrayObject($metadata);

                continue;
            }

            // 'section' is a 'fake' form field used to create the design elements of the
            // complex form layouts: define it as unmapped and non-required
            if (0 === \strpos($metadata['property'], '_easyadmin_form_design_element_')) {
                $formFieldOptions['mapped'] = false;
                $formFieldOptions['required'] = false;
            }

            $formField = $builder->getFormFactory()->createNamedBuilder($name, $formFieldType, null, $formFieldOptions);
            $formField->setAttribute('easyadmin_form_tab', $currentFormTab);
            $formField->setAttribute('easyadmin_form_group', $currentFormGroup);

            $builder->add($formField);
        }

        $builder->setAttribute('easyadmin_form_tabs', $formTabs);
        $builder->setAttribute('easyadmin_form_groups', $formGroups);

        if (\count($formTabs) > 0) {
            $builder->addEventSubscriber(new EasyAdminTabSubscriber());
        }
    }

    /**
     * {@inheritdoc}
     */
    public function finishView(FormView $view, FormInterface $form, array $options)
    {
        $view->vars['easyadmin_form_tabs'] = $form->getConfig()->getAttribute('easyadmin_form_tabs');
        $view->vars['easyadmin_form_groups'] = $form->getConfig()->getAttribute('easyadmin_form_groups');
    }

    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver
            ->setDefaults([
                'allow_extra_fields' => true,
                'data_class' => function (Options $options, $dataClass) {
                    if (null !== $dataClass) {
                        return $dataClass;
                    }

                    $entityConfig = $this->configManager->getEntityConfig($options['entity']);

                    return $entityConfig['class'];
                },
            ])
            ->setRequired(['entity', 'view'])
            ->setNormalizer('attr', $this->getAttributesNormalizer());
    }

    /**
     * {@inheritdoc}
     */
    public function getBlockPrefix()
    {
        return 'easyadmin';
    }

    /**
     * Returns a closure normalizing the form html attributes.
     *
     * @return \Closure
     */
    private function getAttributesNormalizer()
    {
        return function (Options $options, $value) {
            return \array_replace([
                'id' => \sprintf('%s-%s-form', $options['view'], \mb_strtolower($options['entity'])),
            ], $value);
        };
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\EventListener;

use EasyCorp\Bundle\EasyAdminBundle\Exception\BaseException;
use EasyCorp\Bundle\EasyAdminBundle\Exception\FlattenException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpKernel\EventListener\ExceptionListener as BaseExceptionListener;

/**
 * This listener allows to display customized error pages in the production
 * environment.
 *
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 * @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
 */
class ExceptionListener extends BaseExceptionListener
{
    private $twig;
    private $easyAdminConfig;
    private $currentEntityName;

    public function __construct(\Twig_Environment $twig, array $easyAdminConfig, $controller, LoggerInterface $logger = null)
    {
        $this->twig = $twig;
        $this->easyAdminConfig = $easyAdminConfig;

        parent::__construct($controller, $logger);
    }

    public function onKernelException(GetResponseForExceptionEvent $event)
    {
        $exception = $event->getException();
        if (!$exception instanceof BaseException) {
            return;
        }

        $this->currentEntityName = $event->getRequest()->query->get('entity');

        parent::onKernelException($event);
    }

    public function showExceptionPageAction(FlattenException $exception)
    {
        $entityConfig = $this->easyAdminConfig['entities'][$this->currentEntityName] ?? null;
        $exceptionTemplatePath = $entityConfig['templates']['exception']
            ?? $this->easyAdminConfig['design']['templates']['exception']
            ?? '@EasyAdmin/default/exception.html.twig';
        $exceptionLayoutTemplatePath = $entityConfig['templates']['layout']
            ?? $this->easyAdminConfig['design']['templates']['layout']
            ?? '@EasyAdmin/default/layout.html.twig';

        return Response::create($this->twig->render($exceptionTemplatePath, [
            'exception' => $exception,
            'layout_template_path' => $exceptionLayoutTemplatePath,
        ]), $exception->getStatusCode());
    }

    /**
     * {@inheritdoc}
     */
    protected function duplicateRequest(\Exception $exception, Request $request)
    {
        $request = parent::duplicateRequest($exception, $request);
        $request->attributes->set('exception', FlattenException::create($exception));

        return $request;
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Configuration;

use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\Mapping\ClassMetadata;
use Symfony\Component\Config\Definition\Exception\InvalidTypeException;

/**
 * Introspects the metadata of the Doctrine entities to complete the
 * configuration of the properties.
 *
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class MetadataConfigPass implements ConfigPassInterface
{
    /** @var ManagerRegistry */
    private $doctrine;

    public function __construct(ManagerRegistry $doctrine)
    {
        $this->doctrine = $doctrine;
    }

    public function process(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            try {
                $em = $this->doctrine->getManagerForClass($entityConfig['class']);
            } catch (\ReflectionException $e) {
                throw new InvalidTypeException(\sprintf('The configured class "%s" for the path "easy_admin.entities.%s" does not exist. Did you forget to create the entity class or to define its namespace?', $entityConfig['class'], $entityName));
            }

            if (null === $em) {
                throw new InvalidTypeException(\sprintf('The configured class "%s" for the path "easy_admin.entities.%s" is no mapped entity.', $entityConfig['class'], $entityName));
            }

            $entityMetadata = $em->getMetadataFactory()->getMetadataFor($entityConfig['class']);

            $entityConfig['primary_key_field_name'] = $entityMetadata->getSingleIdentifierFieldName();

            $entityConfig['properties'] = $this->processEntityPropertiesMetadata($entityMetadata);

            $backendConfig['entities'][$entityName] = $entityConfig;
        }

        return $backendConfig;
    }

    /**
     * Takes the entity metadata introspected via Doctrine and completes its
     * contents to simplify data processing for the rest of the application.
     *
     * @param ClassMetadata $entityMetadata The entity metadata introspected via Doctrine
     *
     * @return array The entity properties metadata provided by Doctrine
     *
     * @throws \RuntimeException
     */
    private function processEntityPropertiesMetadata(ClassMetadata $entityMetadata)
    {
        $entityPropertiesMetadata = [];

        if ($entityMetadata->isIdentifierComposite) {
            throw new \RuntimeException(\sprintf("The '%s' entity isn't valid because it contains a composite primary key.", $entityMetadata->name));
        }

        // introspect regular entity fields
        foreach ($entityMetadata->fieldMappings as $fieldName => $fieldMetadata) {
            $entityPropertiesMetadata[$fieldName] = $fieldMetadata;
        }

        // introspect fields for entity associations
        foreach ($entityMetadata->associationMappings as $fieldName => $associationMetadata) {
            $entityPropertiesMetadata[$fieldName] = \array_merge($associationMetadata, [
                'type' => 'association',
                'associationType' => $associationMetadata['type'],
            ]);

            // associations different from *-to-one cannot be sorted
            if ($associationMetadata['type'] & ClassMetadata::TO_MANY) {
                $entityPropertiesMetadata[$fieldName]['sortable'] = false;
            }
        }

        return $entityPropertiesMetadata;
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Configuration;

/**
 * Merges all the actions that can be configured in the backend and normalizes
 * them to get the final action configuration for each entity view.
 *
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class ActionConfigPass implements ConfigPassInterface
{
    private $views = ['edit', 'list', 'new', 'show'];
    private $defaultActionConfig = [
        // either the name of a controller method or an application route (it depends on the 'type' option)
        'name' => null,
        // 'method' if the action is a controller method; 'route' if it's an application route
        'type' => 'method',
        // action label (displayed as link or button) (if 'null', autogenerate it)
        'label' => null,
        // the HTML title attribute of the action link (useful when action only displays its icon)
        'title' => null,
        // the CSS class applied to the button/link displayed by the action
        'css_class' => null,
        // the name of the FontAwesome icon to display next to the 'label' (doesn't include the 'fa-' prefix)
        'icon' => null,
        // the value of the HTML 'target' attribute add to the links of the actions (e.g. '_blank')
        'target' => '_self',
    ];

    public function process(array $backendConfig)
    {
        $backendConfig = $this->processDisabledActions($backendConfig);
        $backendConfig = $this->normalizeActionsConfig($backendConfig);
        $backendConfig = $this->normalizeBatchActionsConfig($backendConfig);
        $backendConfig = $this->resolveActionInheritance($backendConfig);
        $backendConfig = $this->resolveBatchActionInheritance($backendConfig);
        $backendConfig = $this->processActionsConfig($backendConfig);
        $backendConfig = $this->processBatchActionsConfig($backendConfig);

        return $backendConfig;
    }

    private function processDisabledActions(array $backendConfig): array
    {
        $actionsDisabledByBackend = $backendConfig['disabled_actions'];
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            $actionsDisabledByEntity = $entityConfig['disabled_actions'] ?? [];
            $disabledActions = \array_unique(\array_merge($actionsDisabledByBackend, $actionsDisabledByEntity));

            $backendConfig['entities'][$entityName]['disabled_actions'] = $disabledActions;
        }

        return $backendConfig;
    }

    /**
     * Transforms the different action configuration formats into a normalized
     * and expanded format. These are the two simple formats allowed:.
     *
     * # Config format #1: no custom option
     * easy_admin:
     *     entities:
     *         User:
     *             list:
     *                 actions: ['search', 'show', 'grantAccess']
     *
     * # Config format #2: one or more actions define any of their options
     * easy_admin:
     *     entities:
     *         User:
     *             list:
     *                 actions: ['search', { name: 'show', label: 'Show', 'icon': 'user' }, 'grantAccess']
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function normalizeActionsConfig(array $backendConfig)
    {
        // first, normalize actions defined globally for the entire backend
        foreach ($this->views as $view) {
            $actionsConfig = $backendConfig[$view]['actions'];
            $actionsConfig = $this->doNormalizeActionsConfig($actionsConfig, \sprintf('the global "%s" view defined under "easy_admin" option', $view));
            $actionsConfig = $this->doNormalizeDefaultActionsConfig($actionsConfig, $view);

            $backendConfig[$view]['actions'] = $actionsConfig;
        }

        // second, normalize actions defined for each entity
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach ($this->views as $view) {
                $actionsConfig = $entityConfig[$view]['actions'];
                $actionsConfig = $this->doNormalizeActionsConfig($actionsConfig, \sprintf('the "%s" view of the "%s" entity', $view, $entityName));
                $actionsConfig = $this->doNormalizeDefaultActionsConfig($actionsConfig, $view);

                $backendConfig['entities'][$entityName][$view]['actions'] = $actionsConfig;
            }

            if (!array_key_exists('collapse_actions', $entityConfig['list'])) {
                $backendConfig['entities'][$entityName]['list']['collapse_actions'] = $backendConfig['list']['collapse_actions'];
            }
        }

        return $backendConfig;
    }

    /**
     * Transforms the different batch action configuration formats into a normalized
     * and expanded format. These are the two simple formats allowed:.
     *
     * # Config format #1: no custom option
     * easy_admin:
     *     entities:
     *         User:
     *             list:
     *                 batch_actions: ['delete', 'reset']
     *
     * # Config format #2: one or more actions define any of their options
     * easy_admin:
     *     entities:
     *         User:
     *             list:
     *                 batch_actions: ['delete', { name: 'reset', label: 'Reset Account' }]
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function normalizeBatchActionsConfig(array $backendConfig)
    {
        // first, normalize batch actions defined globally for the entire backend
        $batchActionsConfig = $backendConfig['list']['batch_actions'];
        $batchActionsConfig = $this->doNormalizeActionsConfig($batchActionsConfig, 'the global "list" view defined under "easy_admin" option');

        $backendConfig['list']['batch_actions'] = $batchActionsConfig;
        foreach ($backendConfig['list']['batch_actions'] as $actionName => $actionConfig) {
            if (null === $actionConfig['css_class'] && 'delete' === $actionName) {
                $backendConfig['list']['batch_actions'][$actionName]['css_class'] = 'btn-danger';
            }
        }

        // second, normalize batch actions defined for each entity
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            $batchActionsConfig = $entityConfig['list']['batch_actions'];
            $batchActionsConfig = $this->doNormalizeActionsConfig($batchActionsConfig, \sprintf('the "list" view of the "%s" entity', $entityName));

            foreach ($batchActionsConfig as $actionName => $actionConfig) {
                if (null === $actionConfig['css_class'] && 'delete' === $actionName) {
                    $batchActionsConfig[$actionName]['css_class'] = 'btn-danger';
                }
            }

            $backendConfig['entities'][$entityName]['list']['batch_actions'] = $batchActionsConfig;
        }

        return $backendConfig;
    }

    private function doNormalizeActionsConfig(array $actionsConfig, $errorOrigin = '')
    {
        $normalizedConfig = [];
        $builtInActions = ['delete', 'edit', 'new', 'search', 'show', 'list'];

        foreach ($actionsConfig as $i => $actionConfig) {
            if (!\is_string($actionConfig) && !\is_array($actionConfig)) {
                throw new \RuntimeException(\sprintf('One of the actions defined by %s contains an invalid value (action config can only be a YAML string or hash).', $errorOrigin));
            }

            // config format #1
            if (\is_string($actionConfig)) {
                $actionConfig = ['name' => $actionConfig];
            }

            $actionConfig = \array_merge($this->defaultActionConfig, $actionConfig);

            // 'name' is the only mandatory option for actions (it might
            // be missing when using the config format #2)
            if (!isset($actionConfig['name'])) {
                throw new \RuntimeException(\sprintf('One of the actions defined by %s does not define its name, which is the only mandatory option for actions.', $errorOrigin));
            }

            $actionName = $actionConfig['name'];

            if (null === $actionConfig['label']) {
                if (\in_array($actionName, $builtInActions, true)) {
                    $actionConfig['label'] = 'action.'.$actionName;
                } else {
                    $actionConfig['label'] = $this->humanizeString($actionName);
                }
            }

            $normalizedConfig[$actionName] = $actionConfig;
        }

        return $normalizedConfig;
    }

    /**
     * If the user overrides the configuration of a default action, they usually
     * define just the options they want to change. For example:
     *   actions: ['delete', 'list'] just to redefine the order
     *   actions: [ { name: 'list', label: 'Listing' }] just to redefine the label.
     *
     * For that reason, this method merges the full configuration of the default
     * actions with the new action configuration. This means that you get the
     * default value for any option that you don't explicitly set (e.g. the icon
     * or the CSS class).
     *
     * @param array  $actionsConfig
     * @param string $view
     *
     * @return array
     */
    private function doNormalizeDefaultActionsConfig(array $actionsConfig, $view)
    {
        $defaultActionsConfig = $this->getDefaultActionsConfig($view);

        foreach ($actionsConfig as $actionName => $actionConfig) {
            if (\array_key_exists($actionName, $defaultActionsConfig)) {
                // remove null config options but maintain empty options (this allows to set an empty label for the action)
                $actionConfig = \array_filter($actionConfig, function ($element) {
                    return null !== $element;
                });
                $actionsConfig[$actionName] = \array_merge($defaultActionsConfig[$actionName], $actionConfig);
            }
        }

        return $actionsConfig;
    }

    /**
     * Actions can be added/removed globally in the edit/list/new/show views of
     * the backend and locally in each of the configured entities. Local config always
     * wins over the global config (e.g. if backend removes 'delete' action in the
     * 'list' view but some action explicitly adds 'delete' in its 'list' view,
     * then that entity shows the 'delete' action and the others don't).
     */
    private function resolveActionInheritance(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach ($this->views as $view) {
                $defaultActions = $this->getDefaultActions($view);
                $backendActions = $backendConfig[$view]['actions'];
                $entityActions = $entityConfig[$view]['actions'];

                // filter actions removed in the global view configuration
                foreach ($backendActions as $backendAction) {
                    if ('-' === $backendAction['name'][0]) {
                        $actionName = \mb_substr($backendAction['name'], 1);

                        unset($backendActions[$actionName], $backendActions['-'.$actionName]);

                        // unless the entity explicitly adds this globally removed action, remove it from the
                        // default actions config to avoid adding it to the entity later when merging everything
                        if (!isset($entityActions[$actionName])) {
                            unset($defaultActions[$actionName]);
                        }
                    }
                }

                // filter actions removed in the local entity configuration
                foreach ($entityActions as $entityAction) {
                    if ('-' === $entityAction['name'][0]) {
                        $actionName = \mb_substr($entityAction['name'], 1);

                        unset($entityActions[$actionName], $entityActions['-'.$actionName], $defaultActions[$actionName], $backendActions[$actionName]);
                    }
                }

                $actionsConfig = \array_merge($defaultActions, $backendActions, $entityActions);

                // reorder the actions to match the order set by the user in the
                // entity or in the global backend options
                if (!empty($entityActions)) {
                    $actionsConfig = $this->reorderArrayItems($actionsConfig, \array_keys($entityActions));
                } elseif (!empty($backendActions)) {
                    $actionsConfig = $this->reorderArrayItems($actionsConfig, \array_keys($backendActions));
                }

                $backendConfig['entities'][$entityName][$view]['actions'] = $actionsConfig;
            }
        }

        return $backendConfig;
    }

    /**
     * Batch actions can be added/removed globally in the list view of
     * the backend and locally in each of the configured entities. Local config always
     * wins over the global config (e.g. if backend removes 'delete' action in the
     * 'list' view but some action explicitly adds 'delete' in its 'list' view,
     * then that entity shows the 'delete' action and the others don't).
     */
    private function resolveBatchActionInheritance(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            $backendBatchActions = $backendConfig['list']['batch_actions'];
            $entityBatchActions = $entityConfig['list']['batch_actions'];

            // filter batch actions removed in the global view configuration
            foreach ($backendBatchActions as $backendAction) {
                if ('-' === $backendAction['name'][0]) {
                    $actionName = \mb_substr($backendAction['name'], 1);

                    unset($backendBatchActions[$actionName], $backendBatchActions['-'.$actionName]);
                }
            }

            // filter batch actions removed in the local entity configuration
            foreach ($entityBatchActions as $entityAction) {
                if ('-' === $entityAction['name'][0]) {
                    $actionName = \mb_substr($entityAction['name'], 1);

                    unset($entityBatchActions[$actionName], $entityBatchActions['-'.$actionName], $backendBatchActions[$actionName]);
                }
            }

            $batchActionsConfig = \array_merge($backendBatchActions, $entityBatchActions);

            // reorder the batch actions to match the order set by the user in the
            // entity or in the global backend options
            if (!empty($entityBatchActions)) {
                $batchActionsConfig = $this->reorderArrayItems($batchActionsConfig, \array_keys($entityBatchActions));
            } elseif (!empty($backendBatchActions)) {
                $batchActionsConfig = $this->reorderArrayItems($batchActionsConfig, \array_keys($backendBatchActions));
            }

            $backendConfig['entities'][$entityName]['list']['batch_actions'] = $batchActionsConfig;
        }

        return $backendConfig;
    }

    private function processActionsConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach ($this->views as $view) {
                foreach ($entityConfig[$view]['actions'] as $actionName => $actionConfig) {
                    // 'name' value is used as the class method name or the Symfony route name
                    // check that its value complies with the PHP method name rules
                    if (!$this->isValidMethodName($actionName)) {
                        throw new \InvalidArgumentException(\sprintf('The name of the "%s" action defined in the "%s" view of the "%s" entity contains invalid characters (allowed: letters, numbers, underscores; the first character cannot be a number).', $actionName, $view, $entityName));
                    }

                    // Add default classes ("action-{actionName}") to each action configuration
                    $actionConfig['css_class'] .= ' action-'.$actionName;

                    $backendConfig['entities'][$entityName][$view]['actions'][$actionName] = $actionConfig;
                }
            }
        }

        return $backendConfig;
    }

    private function processBatchActionsConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach ($entityConfig['list']['batch_actions'] as $actionName => $actionConfig) {
                if ('method' !== $actionConfig['type']) {
                    throw new \InvalidArgumentException(sprintf('Batch actions only support "method" type, "%s" given.', $actionConfig['type']));
                }

                // 'name' value is used as the class method name or the Symfony route name
                // check that its value complies with the PHP method name rules
                if (!$this->isValidMethodName($actionName)) {
                    throw new \InvalidArgumentException(\sprintf('The name of the "%s" action defined in the "list" view of the "%s" entity contains invalid characters (allowed: letters, numbers, underscores; the first character cannot be a number).', $actionName, $entityName));
                }

                $backendConfig['entities'][$entityName]['list']['batch_actions'][$actionName] = $actionConfig;
            }
        }

        return $backendConfig;
    }

    /**
     * Returns the default configuration for all the built-in actions of the
     * given view, including the actions which are not enabled by default for
     * that view (e.g. the 'show' action for the 'list' view).
     *
     * @param string $view
     *
     * @return array
     */
    private function getDefaultActionsConfig($view)
    {
        $actions = $this->doNormalizeActionsConfig([
            'delete' => ['name' => 'delete', 'label' => 'action.delete', 'icon' => 'trash-o', 'css_class' => 'btn text-danger'],
            'edit' => ['name' => 'edit', 'label' => 'action.edit', 'icon' => 'edit', 'css_class' => 'btn btn-primary'],
            'new' => ['name' => 'new', 'label' => 'action.new', 'css_class' => 'btn btn-primary'],
            'search' => ['name' => 'search', 'label' => 'action.search'],
            'show' => ['name' => 'show', 'label' => 'action.show'],
            'list' => ['name' => 'list', 'label' => 'action.list', 'css_class' => 'btn btn-link pr-0'],
        ]);

        // minor tweaks for some action + view combinations
        if ('list' === $view) {
            $actions['delete']['icon'] = null;
            $actions['delete']['css_class'] = 'text-danger';
            $actions['edit']['icon'] = null;
            $actions['edit']['css_class'] = '';
            $actions['list']['css_class'] = '';
        }

        return $actions;
    }

    /**
     * Returns the built-in actions defined by EasyAdmin for the given view.
     * This allows to provide some nice defaults for backends that don't
     * define their own actions.
     *
     * @param string $view
     *
     * @return array
     */
    private function getDefaultActions($view)
    {
        $defaultActions = [];
        $defaultActionsConfig = $this->getDefaultActionsConfig($view);

        // actions are displayed in the same order as defined in this array
        $actionsEnabledByView = [
            'edit' => ['delete', 'list'],
            'list' => ['edit', 'delete', 'new', 'search'],
            'new' => ['list'],
            'show' => ['edit', 'delete', 'list'],
        ];

        foreach ($actionsEnabledByView[$view] as $actionName) {
            $defaultActions[$actionName] = $defaultActionsConfig[$actionName];
        }

        return $defaultActions;
    }

    /**
     * Checks whether the given string is valid as a PHP method name.
     *
     * @param string $name
     *
     * @return bool
     */
    private function isValidMethodName($name)
    {
        return 0 !== \preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $name);
    }

    /**
     * copied from Symfony\Component\Form\FormRenderer::humanize()
     * (author: Bernhard Schussek <bschussek@gmail.com>).
     *
     * @param string $content
     *
     * @return string
     */
    private function humanizeString($content)
    {
        return \ucfirst(\mb_strtolower(\trim(\preg_replace(['/([A-Z])/', '/[_\s]+/'], ['_$1', ' '], $content))));
    }

    private function reorderArrayItems(array $originalArray, array $newKeyOrder)
    {
        $newArray = [];
        foreach ($newKeyOrder as $key) {
            if (isset($originalArray[$key])) {
                $newArray[$key] = $originalArray[$key];
            }
        }

        $missingKeys = \array_diff(\array_keys($originalArray), \array_keys($newArray));
        foreach ($missingKeys as $key) {
            $newArray[$key] = $originalArray[$key];
        }

        return $newArray;
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Form\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

/**
 * This form event subscriber helps to provide the tab functionality.
 *
 * @author naitsirch <naitsirch@e.mail.de>
 */
class EasyAdminTabSubscriber implements EventSubscriberInterface
{
    /**
     * {@inheritdoc}
     */
    public static function getSubscribedEvents()
    {
        return [
            FormEvents::POST_SUBMIT => ['handleViolations', -1],
        ];
    }

    /**
     * Deal with form constraint violations. This method has to be executed with
     * a negative priority to make sure that the validation process is done.
     *
     * @param FormEvent $event
     */
    public function handleViolations(FormEvent $event)
    {
        $formTabs = $event->getForm()->getConfig()->getAttribute('easyadmin_form_tabs');

        $firstTabWithErrors = null;
        foreach ($event->getForm() as $child) {
            $errors = $child->getErrors(true);

            if (\count($errors) > 0) {
                $formTab = $child->getConfig()->getAttribute('easyadmin_form_tab');
                $formTabs[$formTab]['errors'] += \count($errors);

                if (null === $firstTabWithErrors) {
                    $firstTabWithErrors = $formTab;
                }
            }
        }

        // ensure that the first tab with errors is displayed
        $firstTab = \key($formTabs);
        if ($firstTab !== $firstTabWithErrors) {
            $formTabs[$firstTab]['active'] = false;
            $formTabs[$firstTabWithErrors]['active'] = true;
        }
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\DataCollector;

use EasyCorp\Bundle\EasyAdminBundle\Configuration\ConfigManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\VarDumper\Cloner\VarCloner;
use Symfony\Component\VarDumper\Dumper\HtmlDumper;
use Symfony\Component\Yaml\Yaml;

/**
 * Collects information about the requests related to EasyAdmin and displays
 * it both in the web debug toolbar and in the profiler.
 *
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class EasyAdminDataCollector extends DataCollector
{
    /** @var ConfigManager */
    private $configManager;

    public function __construct(ConfigManager $configManager)
    {
        $this->configManager = $configManager;
        $this->reset();
    }

    /**
     * {@inheritdoc}
     */
    public function reset()
    {
        $this->data = [
            'num_entities' => 0,
            'request_parameters' => null,
            'current_entity_configuration' => null,
            'backend_configuration' => null,
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function collect(Request $request, Response $response, \Exception $exception = null)
    {
        if ('easyadmin' !== $request->attributes->get('_route')) {
            return;
        }

        $backendConfig = $this->configManager->getBackendConfig();
        $entityName = $request->query->get('entity');
        $currentEntityConfig = \array_key_exists($entityName, $backendConfig['entities']) ? $backendConfig['entities'][$entityName] : [];

        $this->data = [
            'num_entities' => \count($backendConfig['entities']),
            'request_parameters' => $this->getEasyAdminParameters($request),
            'current_entity_configuration' => $currentEntityConfig,
            'backend_configuration' => $backendConfig,
        ];
    }

    /**
     * @param Request $request
     *
     * @return array|null
     */
    private function getEasyAdminParameters(Request $request)
    {
        return [
            'action' => $request->query->get('action'),
            'entity' => $request->query->get('entity'),
            'id' => $request->query->get('id'),
            'sort_field' => $request->query->get('sortField'),
            'sort_direction' => $request->query->get('sortDirection'),
        ];
    }

    /**
     * @return bool
     */
    public function isEasyAdminAction()
    {
        return isset($this->data['num_entities']) && 0 !== $this->data['num_entities'];
    }

    /**
     * @return int
     */
    public function getNumEntities()
    {
        return $this->data['num_entities'];
    }

    /**
     * @return array
     */
    public function getRequestParameters()
    {
        return $this->data['request_parameters'];
    }

    /**
     * @return array
     */
    public function getCurrentEntityConfig()
    {
        return $this->data['current_entity_configuration'];
    }

    /**
     * @return array
     */
    public function getBackendConfig()
    {
        return $this->data['backend_configuration'];
    }

    /**
     * It dumps the contents of the given variable. It tries several dumpers in
     * turn (VarDumper component, Yaml::dump, etc.) and if none is available, it
     * falls back to PHP's var_export().
     *
     * @param mixed $variable
     *
     * @return string
     */
    public function dump($variable)
    {
        if (\class_exists(HtmlDumper::class)) {
            $cloner = new VarCloner();
            $dumper = new HtmlDumper();

            $dumper->dump($cloner->cloneVar($variable), $output = \fopen('php://memory', 'r+b'));
            if (false !== $dumpedData = \stream_get_contents($output, -1, 0)) {
                return $dumpedData;
            }
        }

        if (\class_exists(Yaml::class)) {
            return \sprintf('
%s
', Yaml::dump((array) $variable, 1024));
        }

        return \sprintf('
%s
', \var_export($variable, true));
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return 'easyadmin';
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Search;

use EasyCorp\Bundle\EasyAdminBundle\Configuration\ConfigManager;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
 * It looks for the values of entity which match the given query. It's used for
 * the autocomplete field types.
 *
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 * @author Yonel Ceruto <yonelceruto@gmail.com>
 */
class Autocomplete
{
    private $configManager;
    private $finder;
    private $propertyAccessor;

    public function __construct(ConfigManager $configManager, Finder $finder, PropertyAccessorInterface $propertyAccessor)
    {
        $this->configManager = $configManager;
        $this->finder = $finder;
        $this->propertyAccessor = $propertyAccessor;
    }

    /**
     * Finds the values of the given entity which match the query provided.
     *
     * @param string $entity
     * @param string $query
     * @param int    $page
     *
     * @return array
     *
     * @throws \InvalidArgumentException
     */
    public function find($entity, $query, $page = 1)
    {
        if (empty($entity) || empty($query)) {
            return ['results' => []];
        }

        $backendConfig = $this->configManager->getBackendConfig();
        if (!isset($backendConfig['entities'][$entity])) {
            throw new \InvalidArgumentException(\sprintf('The "entity" argument must contain the name of an entity managed by EasyAdmin ("%s" given).', $entity));
        }

        $paginator = $this->finder->findByAllProperties($backendConfig['entities'][$entity], $query, $page, $backendConfig['show']['max_results']);

        return [
            'results' => $this->processResults($paginator->getCurrentPageResults(), $backendConfig['entities'][$entity]),
            'has_next_page' => $paginator->hasNextPage(),
        ];
    }

    private function processResults($entities, array $targetEntityConfig): array
    {
        $results = [];

        foreach ($entities as $entity) {
            $results[] = [
                'id' => $this->propertyAccessor->getValue($entity, $targetEntityConfig['primary_key_field_name']),
                'text' => (string) $entity,
            ];
        }

        return $results;
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\DependencyInjection;

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;

/**
 * Resolves all the backend configuration values and most of the entities
 * configuration. The information that must resolved during runtime is handled
 * by the Configurator class.
 *
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class EasyAdminExtension extends Extension
{
    /**
     * {@inheritdoc}
     */
    public function load(array $configs, ContainerBuilder $container)
    {
        // process bundle's configuration parameters
        $configs = $this->processConfigFiles($configs);
        $backendConfig = $this->processConfiguration(new Configuration(), $configs);
        $container->setParameter('easyadmin.config', $backendConfig);

        // load bundle's services
        $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.xml');
        $loader->load('form.xml');

        if ($container->getParameter('kernel.debug')) {
            // in 'dev', use the built-in Symfony exception listener
            $container->removeDefinition('easyadmin.listener.exception');
        }

        if ($container->hasParameter('locale')) {
            $container->getDefinition('easyadmin.configuration.design_config_pass')
                ->replaceArgument(0, $container->getParameter('locale'));
        }
    }

    /**
     * This method allows to define the entity configuration is several files.
     * Without this, Symfony doesn't merge correctly the 'entities' config key
     * defined in different files.
     *
     * @param array $configs
     *
     * @return array
     */
    private function processConfigFiles(array $configs)
    {
        $existingEntityNames = [];

        foreach ($configs as $i => $config) {
            if (\array_key_exists('entities', $config)) {
                $processedConfig = [];

                foreach ($config['entities'] as $key => $value) {
                    $entityConfig = $this->normalizeEntityConfig($key, $value);
                    $entityName = $this->getUniqueEntityName($key, $entityConfig, $existingEntityNames);
                    $entityConfig['name'] = $entityName;

                    $processedConfig[$entityName] = $entityConfig;

                    $existingEntityNames[] = $entityName;
                }

                $config['entities'] = $processedConfig;
            }

            $configs[$i] = $config;
        }

        return $configs;
    }

    /**
     * Transforms the two simple configuration formats into the full expanded
     * configuration. This allows to reuse the same method to process any of the
     * different configuration formats.
     *
     * These are the two simple formats allowed:
     *
     * # Config format #1: no custom entity name
     * easy_admin:
     *     entities:
     *         - AppBundle\Entity\User
     *
     * # Config format #2: simple config with custom entity name
     * easy_admin:
     *     entities:
     *         User: AppBundle\Entity\User
     *
     * And this is the full expanded configuration syntax generated by this method:
     *
     * # Config format #3: expanded entity configuration with 'class' parameter
     * easy_admin:
     *     entities:
     *         User:
     *             class: AppBundle\Entity\User
     *
     * @param mixed $entityName
     * @param mixed $entityConfig
     *
     * @return array
     *
     * @throws \RuntimeException
     */
    private function normalizeEntityConfig($entityName, $entityConfig)
    {
        // normalize config formats #1 and #2 to use the 'class' option as config format #3
        if (!\is_array($entityConfig)) {
            $entityConfig = ['class' => $entityConfig];
        }

        // if config format #3 is used, ensure that it defines the 'class' option
        if (!isset($entityConfig['class'])) {
            throw new \RuntimeException(\sprintf('The "%s" entity must define its associated Doctrine entity class using the "class" option.', $entityName));
        }

        return $entityConfig;
    }

    /**
     * The name of the entity is included in the URLs of the backend to define
     * the entity used to perform the operations. Obviously, the entity name
     * must be unique to identify entities unequivocally.
     *
     * This method ensures that the given entity name is unique among all the
     * previously existing entities passed as the second argument. This is
     * achieved by iteratively appending a suffix until the entity name is
     * guaranteed to be unique.
     *
     * @param string $entityName
     * @param array  $entityConfig
     * @param array  $existingEntityNames
     *
     * @return string The entity name transformed to be unique
     */
    private function getUniqueEntityName($entityName, array $entityConfig, array $existingEntityNames)
    {
        // the shortcut config syntax doesn't require to give entities a name
        if (\is_numeric($entityName)) {
            $entityClassParts = \explode('\\', $entityConfig['class']);
            $entityName = \end($entityClassParts);
        }

        $i = 2;
        $uniqueName = $entityName;
        while (\in_array($uniqueName, $existingEntityNames)) {
            $uniqueName = $entityName.($i++);
        }

        $entityName = $uniqueName;

        // make sure that the entity name is valid as a PHP method name
        // (this is required to allow extending the backend with a custom controller)
        if (!$this->isValidMethodName($entityName)) {
            throw new \InvalidArgumentException(\sprintf('The name of the "%s" entity contains invalid characters (allowed: letters, numbers, underscores; the first character cannot be a number).', $entityName));
        }

        return $entityName;
    }

    /**
     * Checks whether the given string is valid as a PHP method name.
     */
    private function isValidMethodName(string $name): bool
    {
        return 0 !== \preg_match('/^-?[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $name);
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Configuration;

use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Normalizes the different configuration formats available for entities, views,
 * actions and properties.
 *
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class NormalizerConfigPass implements ConfigPassInterface
{
    private $defaultViewConfig = [
        'list' => [
            'dql_filter' => null,
            'fields' => [],
        ],
        'search' => [
            'dql_filter' => null,
            'fields' => [],
        ],
        'show' => [
            'fields' => [],
        ],
        'form' => [
            'fields' => [],
            'form_options' => [],
        ],
        'edit' => [
            'fields' => [],
            'form_options' => [],
        ],
        'new' => [
            'fields' => [],
            'form_options' => [],
        ],
    ];

    /** @var ContainerInterface */
    private $container;

    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    public function process(array $backendConfig)
    {
        $backendConfig = $this->normalizeEntityConfig($backendConfig);
        $backendConfig = $this->normalizeViewConfig($backendConfig);
        $backendConfig = $this->normalizePropertyConfig($backendConfig);
        $backendConfig = $this->normalizeFormDesignConfig($backendConfig);
        $backendConfig = $this->normalizeActionConfig($backendConfig);
        $backendConfig = $this->normalizeBatchActionConfig($backendConfig);
        $backendConfig = $this->normalizeFormConfig($backendConfig);
        $backendConfig = $this->normalizeControllerConfig($backendConfig);
        $backendConfig = $this->normalizeTranslationConfig($backendConfig);

        return $backendConfig;
    }

    /**
     * By default the entity name is used as its label (showed in buttons, the
     * main menu, etc.) unless the entity config defines the 'label' option:.
     *
     * easy_admin:
     *     entities:
     *         User:
     *             class: AppBundle\Entity\User
     *             label: 'Clients'
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function normalizeEntityConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            if (!isset($entityConfig['label'])) {
                $backendConfig['entities'][$entityName]['label'] = $entityName;
            }
        }

        return $backendConfig;
    }

    /**
     * Process the configuration of the 'form' view (if any) to complete the
     * configuration of the 'edit' and 'new' views.
     *
     * @param array $backendConfig [description]
     *
     * @return array
     */
    private function normalizeFormConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            if (isset($entityConfig['form'])) {
                $entityConfig['new'] = isset($entityConfig['new']) ? $this->mergeFormConfig($entityConfig['form'], $entityConfig['new']) : $entityConfig['form'];
                $entityConfig['edit'] = isset($entityConfig['edit']) ? $this->mergeFormConfig($entityConfig['form'], $entityConfig['edit']) : $entityConfig['form'];
            }

            $backendConfig['entities'][$entityName] = $entityConfig;
        }

        return $backendConfig;
    }

    /**
     * Normalizes the view configuration when some of them doesn't define any
     * configuration.
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function normalizeViewConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            // if the original 'search' config doesn't define its own DQL filter, use the one form 'list'
            if (!isset($entityConfig['search']) || !\array_key_exists('dql_filter', $entityConfig['search'])) {
                $entityConfig['search']['dql_filter'] = $entityConfig['list']['dql_filter'] ?? null;
            }

            foreach (['edit', 'form', 'list', 'new', 'search', 'show'] as $view) {
                $entityConfig[$view] = \array_replace_recursive(
                    $this->defaultViewConfig[$view],
                    $entityConfig[$view] ?? []
                );
            }

            $backendConfig['entities'][$entityName] = $entityConfig;
        }

        return $backendConfig;
    }

    /**
     * Fields can be defined using two different formats:.
     *
     * # Config format #1: simple configuration
     * easy_admin:
     *     Client:
     *         # ...
     *         list:
     *             fields: ['id', 'name', 'email']
     *
     * # Config format #2: extended configuration
     * easy_admin:
     *     Client:
     *         # ...
     *         list:
     *             fields: ['id', 'name', { property: 'email', label: 'Contact' }]
     *
     * This method processes both formats to produce a common form field configuration
     * format used in the rest of the application.
     *
     * @param array $backendConfig
     *
     * @return array
     *
     * @throws \RuntimeException
     */
    private function normalizePropertyConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            $designElementIndex = 0;
            foreach (['form', 'edit', 'list', 'new', 'search', 'show'] as $view) {
                $fields = [];
                foreach ($entityConfig[$view]['fields'] as $i => $field) {
                    if (!\is_string($field) && !\is_array($field)) {
                        throw new \RuntimeException(\sprintf('The values of the "fields" option for the "%s" view of the "%s" entity can only be strings or arrays.', $view, $entityConfig['class']));
                    }

                    if (\is_string($field)) {
                        // Config format #1: field is just a string representing the entity property
                        $fieldConfig = ['property' => $field];
                    } else {
                        // Config format #1: field is an array that defines one or more
                        // options. Check that either 'property' or 'type' option is set
                        if (!\array_key_exists('property', $field) && !\array_key_exists('type', $field)) {
                            throw new \RuntimeException(\sprintf('One of the values of the "fields" option for the "%s" view of the "%s" entity does not define neither of the mandatory options ("property" or "type").', $view, $entityConfig['class']));
                        }

                        $fieldConfig = $field;
                    }

                    // for 'image' type fields, if the entity defines an 'image_base_path'
                    // option, but the field does not, use the value defined by the entity
                    if (isset($fieldConfig['type']) && 'image' === $fieldConfig['type']) {
                        if (!isset($fieldConfig['base_path']) && isset($entityConfig['image_base_path'])) {
                            $fieldConfig['base_path'] = $entityConfig['image_base_path'];
                        }
                    }

                    // for 'file' type fields, if the entity defines an 'file_base_path'
                    // option, but the field does not, use the value defined by the entity
                    if (isset($fieldConfig['type']) && 'file' === $fieldConfig['type']) {
                        if (!isset($fieldConfig['base_path']) && isset($entityConfig['file_base_path'])) {
                            $fieldConfig['base_path'] = $entityConfig['file_base_path'];
                        }
                    }

                    // fields that don't define the 'property' name are special form design elements
                    $fieldName = $fieldConfig['property'] ?? '_easyadmin_form_design_element_'.$designElementIndex;
                    $fields[$fieldName] = $fieldConfig;
                    ++$designElementIndex;
                }

                $backendConfig['entities'][$entityName][$view]['fields'] = $fields;
            }
        }

        return $backendConfig;
    }

    /**
     * Normalizes the configuration of the special elements that forms may include
     * to create advanced designs (such as dividers and fieldsets).
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function normalizeFormDesignConfig(array $backendConfig)
    {
        // edge case: if the first 'group' type is not the first form field,
        // all the previous form fields are "ungrouped". To avoid design issues,
        // insert an empty 'group' type (no label, no icon) as the first form element.
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach (['form', 'edit', 'new'] as $view) {
                $fieldNumber = 0;

                foreach ($entityConfig[$view]['fields'] as $fieldName => $fieldConfig) {
                    ++$fieldNumber;
                    $isFormDesignElement = !isset($fieldConfig['property']) && isset($fieldConfig['type']);

                    if ($isFormDesignElement && 'tab' === $fieldConfig['type']) {
                        if ($fieldNumber > 1) {
                            $backendConfig['entities'][$entityName][$view]['fields'] = \array_merge(
                                ['_easyadmin_form_design_element_forced_first_tab' => ['type' => 'tab']],
                                $backendConfig['entities'][$entityName][$view]['fields']
                            );
                        }
                        break;
                    }
                }

                $fieldNumber = 0;
                $previousTabFieldNumber = -1;
                $isTheFirstGroupElement = true;

                foreach ($entityConfig[$view]['fields'] as $fieldName => $fieldConfig) {
                    ++$fieldNumber;
                    $isFormDesignElement = !isset($fieldConfig['property']) && isset($fieldConfig['type']);

                    if ($isFormDesignElement && 'tab' === $fieldConfig['type']) {
                        $previousTabFieldNumber = $fieldNumber;
                        $isTheFirstGroupElement = true;
                    } elseif ($isFormDesignElement && 'group' === $fieldConfig['type']) {
                        if ($isTheFirstGroupElement && -1 === $previousTabFieldNumber && $fieldNumber > 1) {
                            // if no tab is used, insert the group at the beginning of the array
                            $backendConfig['entities'][$entityName][$view]['fields'] = \array_merge(
                                ['_easyadmin_form_design_element_forced_first_group' => ['type' => 'group']],
                                $backendConfig['entities'][$entityName][$view]['fields']
                            );
                            break;
                        } elseif ($isTheFirstGroupElement && $previousTabFieldNumber >= 0 && $fieldNumber > $previousTabFieldNumber + 1) {
                            // if tabs are used, we insert the group after the previous tab field into the array
                            $backendConfig['entities'][$entityName][$view]['fields'] = \array_merge(
                                \array_slice($backendConfig['entities'][$entityName][$view]['fields'], 0, $previousTabFieldNumber, true),
                                ['_easyadmin_form_design_element_forced_group_'.$fieldNumber => ['type' => 'group']],
                                \array_slice($backendConfig['entities'][$entityName][$view]['fields'], $previousTabFieldNumber, null, true)
                            );
                        }

                        $isTheFirstGroupElement = false;
                    }
                }
            }
        }

        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach (['form', 'edit', 'new'] as $view) {
                foreach ($entityConfig[$view]['fields'] as $fieldName => $fieldConfig) {
                    // this is a form design element instead of a regular property
                    $isFormDesignElement = !isset($fieldConfig['property']) && isset($fieldConfig['type']);
                    if ($isFormDesignElement && \in_array($fieldConfig['type'], ['divider', 'group', 'section', 'tab'])) {
                        // assign them a property name to add them later as unmapped form fields
                        $fieldConfig['property'] = $fieldName;

                        if ('tab' === $fieldConfig['type'] && empty($fieldConfig['id'])) {
                            // ensures unique IDs like '_easyadmin_form_design_element_0'
                            $fieldConfig['id'] = $fieldConfig['property'];
                        }

                        // transform the form type shortcuts into the real form type short names
                        $fieldConfig['type'] = 'easyadmin_'.$fieldConfig['type'];
                    }

                    $backendConfig['entities'][$entityName][$view]['fields'][$fieldName] = $fieldConfig;
                }
            }
        }

        return $backendConfig;
    }

    private function normalizeActionConfig(array $backendConfig)
    {
        $views = ['edit', 'list', 'new', 'show', 'form'];

        foreach ($views as $view) {
            if (!isset($backendConfig[$view]['actions'])) {
                $backendConfig[$view]['actions'] = [];
            }

            // there is no need to check if the "actions" option for the global
            // view is an array because it's done by the Configuration definition
        }

        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach ($views as $view) {
                if (!isset($entityConfig[$view]['actions'])) {
                    $backendConfig['entities'][$entityName][$view]['actions'] = [];
                }

                if (!\is_array($backendConfig['entities'][$entityName][$view]['actions'])) {
                    throw new \InvalidArgumentException(\sprintf('The "actions" configuration for the "%s" view of the "%s" entity must be an array (a string was provided).', $view, $entityName));
                }
            }
        }

        return $backendConfig;
    }

    private function normalizeBatchActionConfig(array $backendConfig)
    {
        if (!isset($backendConfig['list']['batch_actions'])) {
            $backendConfig['list']['batch_actions'] = [];
        }

        // there is no need to check if the "batch_actions" option for the global
        // view is an array because it's done by the Configuration definition

        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            if (!isset($entityConfig['list']['batch_actions'])) {
                $backendConfig['entities'][$entityName]['list']['batch_actions'] = [];
            }

            if (!\is_array($backendConfig['entities'][$entityName]['list']['batch_actions'])) {
                throw new \InvalidArgumentException(\sprintf('The "batch_actions" configuration for the "list" view of the "%s" entity must be an array (a string was provided).', $entityName));
            }
        }

        return $backendConfig;
    }

    /**
     * It processes the optional 'controller' config option to check if the
     * given controller exists (it doesn't matter if it's a normal controller
     * or if it's defined as a service).
     *
     * @param array $backendConfig
     *
     * @return array
     *
     * @throws \InvalidArgumentException
     */
    private function normalizeControllerConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            if (isset($entityConfig['controller'])) {
                $controller = \trim($entityConfig['controller']);

                if (!$this->container->has($controller) && !\class_exists($controller)) {
                    throw new \InvalidArgumentException(\sprintf('The "%s" value defined in the "controller" option of the "%s" entity is not a valid controller. For a regular controller, set its FQCN as the value; for a controller defined as service, set its service name as the value.', $controller, $entityName));
                }

                $backendConfig['entities'][$entityName]['controller'] = $controller;
            }
        }

        return $backendConfig;
    }

    private function normalizeTranslationConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            if (!isset($entityConfig['translation_domain'])) {
                $entityConfig['translation_domain'] = $backendConfig['translation_domain'];
            }

            if ('' === $entityConfig['translation_domain']) {
                throw new \InvalidArgumentException(\sprintf('The value defined in the "translation_domain" option of the "%s" entity is not a valid translation domain name (use false to disable translations).', $entityName));
            }

            $backendConfig['entities'][$entityName] = $entityConfig;
        }

        return $backendConfig;
    }

    /**
     * Merges the form configuration recursively from the 'form' view to the
     * 'edit' and 'new' views. It processes the configuration of the form fields
     * in a special way to keep all their configuration and allow overriding and
     * removing of fields.
     *
     * @param array $parentConfig The config of the 'form' view
     * @param array $childConfig  The config of the 'edit' and 'new' views
     *
     * @return array
     */
    private function mergeFormConfig(array $parentConfig, array $childConfig)
    {
        // save the fields config for later processing
        $parentFields = $parentConfig['fields'] ?? [];
        $childFields = $childConfig['fields'] ?? [];
        $removedFieldNames = $this->getRemovedFieldNames($childFields);

        // first, perform a recursive replace to merge both configs
        $mergedConfig = \array_replace_recursive($parentConfig, $childConfig);

        // merge the config of each field individually
        $mergedFields = [];
        foreach ($parentFields as $parentFieldName => $parentFieldConfig) {
            if (isset($parentFieldConfig['property']) && \in_array($parentFieldConfig['property'], $removedFieldNames)) {
                continue;
            }

            if (!isset($parentFieldConfig['property'])) {
                // this isn't a regular form field but a special design element (group, section, divider); add it
                $mergedFields[$parentFieldName] = $parentFieldConfig;
                continue;
            }

            $childFieldConfig = $this->findFieldConfigByProperty($childFields, $parentFieldConfig['property']) ?: [];
            $mergedFields[$parentFieldName] = \array_replace_recursive($parentFieldConfig, $childFieldConfig);
        }

        // add back the fields that are defined in child config but not in parent config
        foreach ($childFields as $childFieldName => $childFieldConfig) {
            $isFormDesignElement = !isset($childFieldConfig['property']);
            $isNotRemovedField = isset($childFieldConfig['property']) && 0 !== \strpos($childFieldConfig['property'], '-');
            $isNotAlreadyIncluded = isset($childFieldConfig['property']) && !\array_key_exists($childFieldConfig['property'], $mergedFields);

            if ($isFormDesignElement || ($isNotRemovedField && $isNotAlreadyIncluded)) {
                $mergedFields[$childFieldName] = $childFieldConfig;
            }
        }

        // finally, copy the processed field config into the merged config
        $mergedConfig['fields'] = $mergedFields;

        return $mergedConfig;
    }

    /**
     * The 'edit' and 'new' views can remove fields defined in the 'form' view
     * by defining fields with a '-' dash at the beginning of its name (e.g.
     * { property: '-name' } to remove the 'name' property).
     *
     * @param array $fieldsConfig
     *
     * @return array
     */
    private function getRemovedFieldNames(array $fieldsConfig)
    {
        $removedFieldNames = [];
        foreach ($fieldsConfig as $fieldConfig) {
            if (isset($fieldConfig['property']) && 0 === \strpos($fieldConfig['property'], '-')) {
                $removedFieldNames[] = \mb_substr($fieldConfig['property'], 1);
            }
        }

        return $removedFieldNames;
    }

    private function findFieldConfigByProperty(array $fieldsConfig, $propertyName)
    {
        foreach ($fieldsConfig as $fieldConfig) {
            if (isset($fieldConfig['property']) && $propertyName === $fieldConfig['property']) {
                return $fieldConfig;
            }
        }

        return null;
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Search;

use Doctrine\Bundle\DoctrineBundle\Registry;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\QueryBuilder as DoctrineQueryBuilder;

/**
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class QueryBuilder
{
    /** @var Registry */
    private $doctrine;

    public function __construct(Registry $doctrine)
    {
        $this->doctrine = $doctrine;
    }

    /**
     * Creates the query builder used to get all the records displayed by the
     * "list" view.
     *
     * @param array       $entityConfig
     * @param string|null $sortField
     * @param string|null $sortDirection
     * @param string|null $dqlFilter
     *
     * @return DoctrineQueryBuilder
     */
    public function createListQueryBuilder(array $entityConfig, $sortField = null, $sortDirection = null, $dqlFilter = null)
    {
        /* @var EntityManager $em */
        $em = $this->doctrine->getManagerForClass($entityConfig['class']);
        /* @var ClassMetadata $classMetadata */
        $classMetadata = $em->getClassMetadata($entityConfig['class']);
        /* @var DoctrineQueryBuilder $queryBuilder */
        $queryBuilder = $em->createQueryBuilder()
            ->select('entity')
            ->from($entityConfig['class'], 'entity')
        ;

        $isSortedByDoctrineAssociation = $this->isDoctrineAssociation($classMetadata, $sortField);
        if ($isSortedByDoctrineAssociation) {
            $sortFieldParts = \explode('.', $sortField);
            $queryBuilder->leftJoin('entity.'.$sortFieldParts[0], $sortFieldParts[0]);
        }

        if (!empty($dqlFilter)) {
            $queryBuilder->andWhere($dqlFilter);
        }

        if (null !== $sortField) {
            $queryBuilder->orderBy(\sprintf('%s%s', $isSortedByDoctrineAssociation ? '' : 'entity.', $sortField), $sortDirection);
        }

        return $queryBuilder;
    }

    /**
     * Creates the query builder used to get the results of the search query
     * performed by the user in the "search" view.
     *
     * @param array       $entityConfig
     * @param string      $searchQuery
     * @param string|null $sortField
     * @param string|null $sortDirection
     * @param string|null $dqlFilter
     *
     * @return DoctrineQueryBuilder
     */
    public function createSearchQueryBuilder(array $entityConfig, $searchQuery, $sortField = null, $sortDirection = null, $dqlFilter = null)
    {
        /* @var EntityManager $em */
        $em = $this->doctrine->getManagerForClass($entityConfig['class']);
        /* @var ClassMetadata $classMetadata */
        $classMetadata = $em->getClassMetadata($entityConfig['class']);
        /* @var DoctrineQueryBuilder $queryBuilder */
        $queryBuilder = $em->createQueryBuilder()
            ->select('entity')
            ->from($entityConfig['class'], 'entity')
        ;

        $isSearchQueryNumeric = \is_numeric($searchQuery);
        $isSearchQuerySmallInteger = \ctype_digit($searchQuery) && $searchQuery >= -32768 && $searchQuery <= 32767;
        $isSearchQueryInteger = \ctype_digit($searchQuery) && $searchQuery >= -2147483648 && $searchQuery <= 2147483647;
        $isSearchQueryUuid = 1 === \preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $searchQuery);
        $lowerSearchQuery = \mb_strtolower($searchQuery);

        $queryParameters = [];
        $entitiesAlreadyJoined = [];
        foreach ($entityConfig['search']['fields'] as $fieldName => $metadata) {
            $entityName = 'entity';
            if ($this->isDoctrineAssociation($classMetadata, $fieldName)) {
                // support arbitrarily nested associations (e.g. foo.bar.baz.qux)
                $associationComponents = \explode('.', $fieldName);
                for ($i = 0; $i < \count($associationComponents) - 1; ++$i) {
                    $associatedEntityName = $associationComponents[$i];
                    $associatedFieldName = $associationComponents[$i + 1];

                    if (!\in_array($associatedEntityName, $entitiesAlreadyJoined)) {
                        $parentEntityName = 0 === $i ? 'entity' : $associationComponents[$i - 1];
                        $queryBuilder->leftJoin($parentEntityName.'.'.$associatedEntityName, $associatedEntityName);
                        $entitiesAlreadyJoined[] = $associatedEntityName;
                    }

                    $entityName = $associatedEntityName;
                    $fieldName = $associatedFieldName;
                }
            }

            $isSmallIntegerField = 'smallint' === $metadata['dataType'];
            $isIntegerField = 'integer' === $metadata['dataType'];
            $isNumericField = \in_array($metadata['dataType'], ['number', 'bigint', 'decimal', 'float']);
            // 'citext' is a PostgreSQL extension (https://github.com/EasyCorp/EasyAdminBundle/issues/2556)
            $isTextField = \in_array($metadata['dataType'], ['string', 'text', 'citext', 'array', 'simple_array']);
            $isGuidField = \in_array($metadata['dataType'], ['guid', 'uuid']);

            // this complex condition is needed to avoid issues on PostgreSQL databases
            if (
                ($isSmallIntegerField && $isSearchQuerySmallInteger) ||
                ($isIntegerField && $isSearchQueryInteger) ||
                ($isNumericField && $isSearchQueryNumeric)
            ) {
                $queryBuilder->orWhere(\sprintf('%s.%s = :numeric_query', $entityName, $fieldName));
                // adding '0' turns the string into a numeric value
                $queryParameters['numeric_query'] = 0 + $searchQuery;
            } elseif ($isGuidField && $isSearchQueryUuid) {
                $queryBuilder->orWhere(\sprintf('%s.%s = :uuid_query', $entityName, $fieldName));
                $queryParameters['uuid_query'] = $searchQuery;
            } elseif ($isTextField) {
                $queryBuilder->orWhere(\sprintf('LOWER(%s.%s) LIKE :fuzzy_query', $entityName, $fieldName));
                $queryParameters['fuzzy_query'] = '%'.$lowerSearchQuery.'%';

                $queryBuilder->orWhere(\sprintf('LOWER(%s.%s) IN (:words_query)', $entityName, $fieldName));
                $queryParameters['words_query'] = \explode(' ', $lowerSearchQuery);
            }
        }

        if (0 !== \count($queryParameters)) {
            $queryBuilder->setParameters($queryParameters);
        }

        if (!empty($dqlFilter)) {
            $queryBuilder->andWhere($dqlFilter);
        }

        $isSortedByDoctrineAssociation = $this->isDoctrineAssociation($classMetadata, $sortField);
        if ($isSortedByDoctrineAssociation) {
            $associatedEntityName = \explode('.', $sortField)[0];
            if (!\in_array($associatedEntityName, $entitiesAlreadyJoined)) {
                $queryBuilder->leftJoin('entity.'.$associatedEntityName, $associatedEntityName);
                $entitiesAlreadyJoined[] = $associatedEntityName;
            }
        }

        if (null !== $sortField) {
            $queryBuilder->orderBy(\sprintf('%s%s', $isSortedByDoctrineAssociation ? '' : 'entity.', $sortField), $sortDirection ?: 'DESC');
        }

        return $queryBuilder;
    }

    /**
     * Checking if the field name contains a '.' is not enough to decide if it's a
     * Doctrine association. This also happens when using embedded classes, so the
     * embeddedClasses property from Doctrine class metadata must be checked too.
     *
     * @param ClassMetadata $classMetadata
     * @param string|null   $fieldName
     *
     * @return bool
     */
    protected function isDoctrineAssociation(ClassMetadata $classMetadata, $fieldName)
    {
        if (null === $fieldName) {
            return false;
        }

        $fieldNameParts = \explode('.', $fieldName);

        return false !== \strpos($fieldName, '.') && !\array_key_exists($fieldNameParts[0], $classMetadata->embeddedClasses);
    }
}


<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Configuration;

/**
 * Initializes the configuration for all the views of each entity, which is
 * needed when some entity relies on the default configuration for some view.
 *
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class ViewConfigPass implements ConfigPassInterface
{
    public function process(array $backendConfig)
    {
        $backendConfig = $this->processViewConfig($backendConfig);
        $backendConfig = $this->processDefaultFieldsConfig($backendConfig);
        $backendConfig = $this->processFieldConfig($backendConfig);
        $backendConfig = $this->processPageTitleConfig($backendConfig);
        $backendConfig = $this->processMaxResultsConfig($backendConfig);
        $backendConfig = $this->processSortingConfig($backendConfig);

        return $backendConfig;
    }

    private function processViewConfig(array $backendConfig)
    {
        // process the 'help' message that each view can define to display it under the page title
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach (['edit', 'list', 'new', 'search', 'show'] as $view) {
                // isset() cannot be used because the value can be 'null' (used to remove the inherited help message)
                if (\array_key_exists('help', $backendConfig['entities'][$entityName][$view])) {
                    continue;
                }

                $backendConfig['entities'][$entityName][$view]['help'] = $entityConfig['help'] ?? null;
            }
        }

        return $backendConfig;
    }

    /**
     * This method takes care of the views that don't define their fields. In
     * those cases, we just use the $entityConfig['properties'] information and
     * we filter some fields to improve the user experience for default config.
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function processDefaultFieldsConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach (['edit', 'list', 'new', 'search', 'show'] as $view) {
                if (0 === \count($entityConfig[$view]['fields'])) {
                    $fieldsConfig = $this->filterFieldList(
                        $entityConfig['properties'],
                        $this->getExcludedFieldNames($view, $entityConfig),
                        $this->getExcludedFieldTypes($view),
                        $this->getMaxNumberFields($view)
                    );

                    foreach ($fieldsConfig as $fieldName => $fieldConfig) {
                        if (null === $fieldsConfig[$fieldName]['format']) {
                            $fieldsConfig[$fieldName]['format'] = $this->getFieldFormat($fieldConfig['type'], $backendConfig);
                        }
                    }

                    $backendConfig['entities'][$entityName][$view]['fields'] = $fieldsConfig;
                }
            }
        }

        return $backendConfig;
    }

    /**
     * This methods makes some minor tweaks in fields configuration to improve
     * the user experience.
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function processFieldConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach (['edit', 'list', 'new', 'search', 'show'] as $view) {
                foreach ($entityConfig[$view]['fields'] as $fieldName => $fieldConfig) {
                    if (!isset($fieldConfig['label']) && 'id' === $fieldConfig['property']) {
                        // if the field is called 'id' and doesn't define a custom label, use 'ID' as label to
                        // improve the readability of the label, which is usually related to a primary key
                        $fieldConfig['label'] = 'ID';
                    } elseif (isset($fieldConfig['label']) && false === $fieldConfig['label']) {
                        // if the label is the special value 'false', label must be hidden (use an empty string as the label)
                        $fieldConfig['label'] = '';
                        $fieldConfig['sortable'] = false;
                    } elseif (null === $fieldConfig['label'] && 0 !== strpos($fieldConfig['property'], '_easyadmin_form_design_element_')) {
                        // else, generate the label automatically from its name (except if it's a
                        // special element created to render complex forms)
                        $fieldConfig['label'] = $this->humanize($fieldConfig['property']);
                    }

                    $backendConfig['entities'][$entityName][$view]['fields'][$fieldName] = $fieldConfig;
                }
            }
        }

        return $backendConfig;
    }

    /**
     * This method resolves the page title inheritance when some global view
     * (list, edit, etc.) defines a global title for all entities that can be
     * overridden individually by each entity.
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function processPageTitleConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach (['edit', 'list', 'new', 'search', 'show'] as $view) {
                if (!isset($entityConfig[$view]['title']) && isset($backendConfig[$view]['title'])) {
                    $backendConfig['entities'][$entityName][$view]['title'] = $backendConfig[$view]['title'];
                }
            }
        }

        return $backendConfig;
    }

    /**
     * This method resolves the 'max_results' inheritance when some global view
     * (list, show, etc.) defines a global value for all entities that can be
     * overridden individually by each entity.
     *
     * @param array $backendConfig
     *
     * @return array
     */
    private function processMaxResultsConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach (['list', 'search', 'show'] as $view) {
                if (!isset($entityConfig[$view]['max_results']) && isset($backendConfig[$view]['max_results'])) {
                    $backendConfig['entities'][$entityName][$view]['max_results'] = $backendConfig[$view]['max_results'];
                }
            }
        }

        return $backendConfig;
    }

    /**
     * This method processes the optional 'sort' config that the 'list' and
     * 'search' views can define to override the default (id, DESC) sorting
     * applied to their contents.
     *
     * @param array $backendConfig
     *
     * @return array
     *
     * @throws \InvalidArgumentException
     */
    private function processSortingConfig(array $backendConfig)
    {
        foreach ($backendConfig['entities'] as $entityName => $entityConfig) {
            foreach (['list', 'search'] as $view) {
                if (!isset($entityConfig[$view]['sort'])) {
                    continue;
                }

                $sortConfig = $entityConfig[$view]['sort'];
                if (!\is_string($sortConfig) && !\is_array($sortConfig)) {
                    throw new \InvalidArgumentException(\sprintf('The "sort" option of the "%s" view of the "%s" entity contains an invalid value (it can only be a string or an array).', $view, $entityName));
                }

                if (\is_string($sortConfig)) {
                    $sortConfig = ['field' => $sortConfig, 'direction' => 'DESC'];
                } else {
                    $sortConfig = ['field' => $sortConfig[0], 'direction' => \strtoupper($sortConfig[1])];
                }

                if (!\in_array($sortConfig['direction'], ['ASC', 'DESC'])) {
                    throw new \InvalidArgumentException(\sprintf('If defined, the second value of the "sort" option of the "%s" view of the "%s" entity can only be "ASC" or "DESC".', $view, $entityName));
                }

                $isSortedByDoctrineAssociation = false !== \strpos($sortConfig['field'], '.');
                if (!$isSortedByDoctrineAssociation && (isset($entityConfig[$view]['fields'][$sortConfig['field']]) && true === $entityConfig[$view]['fields'][$sortConfig['field']]['virtual'])) {
                    throw new \InvalidArgumentException(\sprintf('The "%s" field cannot be used in the "sort" option of the "%s" view of the "%s" entity because it\'s a virtual property that is not persisted in the database.', $sortConfig['field'], $view, $entityName));
                }

                // sort can be defined using simple properties (sort: author) or association properties (sort: author.name)
                if (\substr_count($sortConfig['field'], '.') > 1) {
                    throw new \InvalidArgumentException(\sprintf('The "%s" value cannot be used as the "sort" option in the "%s" view of the "%s" entity because it defines multiple sorting levels (e.g. "aaa.bbb.ccc") but only up to one level is supported (e.g. "aaa.bbb").', $sortConfig['field'], $view, $entityName));
                }

                // sort field can be a Doctrine association (sort: author.name) instead of a simple property
                $sortFieldParts = \explode('.', $sortConfig['field']);
                $sortFieldProperty = $sortFieldParts[0];

                if (!\array_key_exists($sortFieldProperty, $entityConfig['properties']) && !isset($entityConfig[$view]['fields'][$sortFieldProperty])) {
                    throw new \InvalidArgumentException(\sprintf('The "%s" field used in the "sort" option of the "%s" view of the "%s" entity does not exist neither as a property of that entity nor as a virtual field of that view.', $sortFieldProperty, $view, $entityName));
                }

                $backendConfig['entities'][$entityName][$view]['sort'] = $sortConfig;
            }
        }

        return $backendConfig;
    }

    /**
     * Returns the date/time/datetime/number format for the given field
     * according to its type and the default formats defined for the backend.
     *
     * @param string $fieldType
     * @param array  $backendConfig
     *
     * @return string The format that should be applied to the field value
     */
    private function getFieldFormat($fieldType, array $backendConfig)
    {
        if (\in_array($fieldType, ['date', 'date_immutable', 'dateinterval', 'time', 'time_immutable', 'datetime', 'datetime_immutable', 'datetimetz'])) {
            // make 'datetimetz' use the same format as 'datetime'
            $fieldType = ('datetimetz' === $fieldType) ? 'datetime' : $fieldType;
            $fieldType = ('_immutable' === \mb_substr($fieldType, -10)) ? \mb_substr($fieldType, 0, -10) : $fieldType;

            return $backendConfig['formats'][$fieldType];
        }

        if (\in_array($fieldType, ['bigint', 'integer', 'smallint', 'decimal', 'float'])) {
            return isset($backendConfig['formats']['number']) ? $backendConfig['formats']['number'] : null;
        }
    }

    /**
     * Returns the list of excluded field names for the given view.
     *
     * @param string $view
     * @param array  $entityConfig
     *
     * @return array
     */
    private function getExcludedFieldNames($view, array $entityConfig)
    {
        $excludedFieldNames = [
            'edit' => [$entityConfig['primary_key_field_name']],
            'list' => ['password', 'salt', 'slug', 'updatedAt', 'uuid'],
            'new' => [$entityConfig['primary_key_field_name']],
            'search' => ['password', 'salt'],
            'show' => [],
        ];

        return isset($excludedFieldNames[$view]) ? $excludedFieldNames[$view] : [];
    }

    /**
     * Returns the list of excluded field types for the given view.
     *
     * @param string $view
     *
     * @return array
     */
    private function getExcludedFieldTypes($view)
    {
        $excludedFieldTypes = [
            'edit' => ['binary', 'blob', 'json_array', 'json', 'object'],
            'list' => ['array', 'binary', 'blob', 'guid', 'json_array', 'json', 'object', 'simple_array', 'text'],
            'new' => ['binary', 'blob', 'json_array', 'json', 'object'],
            'search' => ['association', 'binary', 'boolean', 'blob', 'date', 'date_immutable', 'datetime', 'datetime_immutable', 'dateinterval', 'datetimetz', 'time', 'time_immutable', 'object'],
            'show' => [],
        ];

        return isset($excludedFieldTypes[$view]) ? $excludedFieldTypes[$view] : [];
    }

    /**
     * Returns the maximum number of fields to display be default for the
     * given view.
     *
     * @param string $view
     *
     * @return int
     */
    private function getMaxNumberFields($view)
    {
        $maxNumberFields = [
            'list' => 7,
        ];

        return isset($maxNumberFields[$view]) ? $maxNumberFields[$view] : PHP_INT_MAX;
    }

    /**
     * Filters a list of fields excluding the given list of field names and field types.
     *
     * @param array    $fields
     * @param string[] $excludedFieldNames
     * @param string[] $excludedFieldTypes
     * @param int      $maxNumFields
     *
     * @return array The filtered list of fields
     */
    private function filterFieldList(array $fields, array $excludedFieldNames, array $excludedFieldTypes, $maxNumFields)
    {
        $filteredFields = [];

        foreach ($fields as $name => $metadata) {
            if (!\in_array($name, $excludedFieldNames) && !\in_array($metadata['type'], $excludedFieldTypes)) {
                $filteredFields[$name] = $fields[$name];
            }
        }

        if (\count($filteredFields) > $maxNumFields) {
            $filteredFields = \array_slice($filteredFields, 0, $maxNumFields, true);
        }

        return $filteredFields;
    }

    // Copied from Symfony\Component\Form\FormRenderer::humanize()
    // @author Bernhard Schussek <bschussek@gmail.com>
    private function humanize(string $value): string
    {
        return ucfirst(strtolower(trim(preg_replace(array('/([A-Z])/', '/[_\s]+/'), array('_$1', ' '), $value))));
    }
}


<?php
/**
 * Author: Louis Livi <574747417@qq.com>
 * Date: 2018/11/23
 * Time: 下午1:04.
 */

namespace SMProxy\Command;

use function SMProxy\Helper\initConfig;
use SMProxy\Helper\PhpHelper;
use function SMProxy\Helper\smproxy_error;

class Command
{
    /**
     * 运行
     *
     * @param array $argv
     *
     * @throws \SMProxy\SMProxyException
     */
    public function run(array $argv)
    {
        $command = count($argv) >= 2 ? $argv[1] : false;
        $this ->settingConfig($argv);
        $this ->commandHandler($command);
    }

    /**
     * 设置配置文件
     *
     * @param array $argv
     *
     * @throws \SMProxy\SMProxyException
     */
    protected function settingConfig(array $argv)
    {
        //指定配置文件
        $configPath = ROOT . '/conf/';
        $configKey  = array_search('-c', $argv) ?: array_search('--config', $argv);
        if ($configKey) {
            if (!isset($argv[$configKey + 1])) {
                smproxy_error(HelpMessage::$version . PHP_EOL . HelpMessage::$usage);
            }
            $configPath = $argv[$configKey + 1];
        }

        //前台运行
        $consoleKey  = array_search('--console', $argv);
        if ($consoleKey) {
            define('CONSOLE', true);
        } else {
            define('CONSOLE', false);
        }

        if (file_exists($configPath)) {
            define('CONFIG_PATH', realpath($configPath) . '/');
            define('CONFIG', initConfig(CONFIG_PATH));
        } else {
            smproxy_error('ERROR: ' . $configPath . ' No such file or directory!');
        }
    }

    /**
     * 处理命令
     *
     * @param string $command
     */
    protected function commandHandler(string $command)
    {
        $serverCommand = new ServerCommand();

        if ('-h' == $command || '--help' == $command) {
            echo $serverCommand->desc, PHP_EOL;

            return;
        }

        if ('-v' == $command || '--version' == $command) {
            echo $serverCommand->logo, PHP_EOL;

            return;
        }

        if (!$command || !method_exists($serverCommand, $command)) {
            echo $serverCommand->usage, PHP_EOL;

            return;
        }

        PhpHelper::call([$serverCommand, $command]);
    }
}


<?php
/**
 * Author: Louis Livi <574747417@qq.com>
 * Date: 2018/11/23
 * Time: 下午12:16
 */

namespace SMProxy\Helper;

/**
 * 进程帮助类
 *
 */
class ProcessHelper
{
    /**
     * 设置当前进程名称
     *
     * @param string $title 名称
     *
     * @return bool
     */
    public static function setProcessTitle(string $title): bool
    {
        if (PhpHelper::isMac()) {
            return false;
        }

        if (\function_exists('cli_set_process_title')) {
            return @cli_set_process_title($title);
        }

        return true;
    }

    /**
     * run a command. it is support windows
     * @param string $command
     * @param string|null $cwd
     * @return array
     * @throws \RuntimeException
     */
    public static function run(string $command, string $cwd = null): array
    {
        $descriptors = [
            0 => ['pipe', 'r'], // stdin - read channel
            1 => ['pipe', 'w'], // stdout - write channel
            2 => ['pipe', 'w'], // stdout - error channel
            3 => ['pipe', 'r'], // stdin - This is the pipe we can feed the password into
        ];

        $process = proc_open($command, $descriptors, $pipes, $cwd);

        if (!\is_resource($process)) {
            throw new \RuntimeException('Can\'t open resource with proc_open.');
        }

        // Nothing to push to input.
        fclose($pipes[0]);

        $output = stream_get_contents($pipes[1]);
        fclose($pipes[1]);

        $error = stream_get_contents($pipes[2]);
        fclose($pipes[2]);

        fclose($pipes[3]);

        // Close all pipes before proc_close! $code === 0 is success.
        $code = proc_close($process);

        return [trim($code), trim($output), trim($error)];
    }
}