From a69202e94156bebad2eef7d85bc471ffa4e24ada Mon Sep 17 00:00:00 2001 From: Sergey Zvyagintsev Date: Fri, 9 Jul 2021 07:09:19 +0700 Subject: [PATCH] Split configuration processing to different services. Implement schema configuration validation and nesting loading. Add schema validation console command. --- Command/ValidateDataSchemaCommand.php | 105 ++ Configuration/DataSchemaConfiguration.php | 125 ++ DataSchema/DataSchema.php | 1107 ++++++++--------- DataSchema/DataSchemaFactory.php | 168 +-- DataSchema/Persister/EntityPersister.php | 67 +- DependencyInjection/Configuration.php | 1 + .../GlavwebDataSchemaExtension.php | 7 +- .../InvalidConfigurationException.php | 12 +- .../InvalidConfigurationPropertyException.php | 21 + .../DataTransformerNotExists.php | 22 + Loader/Yaml/DataSchemaYamlLoader.php | 2 +- Loader/Yaml/ScopeYamlLoader.php | 2 +- Resources/config/services.yml | 38 +- Service/DataSchemaFilter.php | 147 +++ Service/DataSchemaService.php | 322 +++++ Service/DataSchemaValidator.php | 278 +++++ Util/Utils.php | 34 + composer.json | 1 + 18 files changed, 1744 insertions(+), 715 deletions(-) create mode 100644 Command/ValidateDataSchemaCommand.php create mode 100755 Configuration/DataSchemaConfiguration.php mode change 100644 => 100755 DataSchema/DataSchema.php mode change 100644 => 100755 DataSchema/DataSchemaFactory.php mode change 100644 => 100755 DataSchema/Persister/EntityPersister.php mode change 100644 => 100755 DependencyInjection/Configuration.php mode change 100644 => 100755 DependencyInjection/GlavwebDataSchemaExtension.php mode change 100644 => 100755 Exception/DataSchema/InvalidConfigurationException.php create mode 100644 Exception/DataSchema/InvalidConfigurationPropertyException.php create mode 100644 Exception/DataTransformer/DataTransformerNotExists.php mode change 100644 => 100755 Loader/Yaml/DataSchemaYamlLoader.php mode change 100644 => 100755 Loader/Yaml/ScopeYamlLoader.php mode change 100644 => 100755 Resources/config/services.yml create mode 100644 Service/DataSchemaFilter.php create mode 100644 Service/DataSchemaService.php create mode 100644 Service/DataSchemaValidator.php create mode 100755 Util/Utils.php mode change 100644 => 100755 composer.json diff --git a/Command/ValidateDataSchemaCommand.php b/Command/ValidateDataSchemaCommand.php new file mode 100644 index 0000000..2a35d1e --- /dev/null +++ b/Command/ValidateDataSchemaCommand.php @@ -0,0 +1,105 @@ +setName('glavweb:data-schema:validate') + ->setDescription('Validates data schema configuration file') + ->addArgument( + 'path', + InputArgument::OPTIONAL, + 'File path relative to directory defined in "data_schema.dir" bundle configuration parameter. Validates all files in folder if not specified.' + ); + } + + /** + * @inheritDoc + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + /** @var DataSchemaValidator $dataSchemaValidator */ + $dataSchemaValidator = $this->getContainer()->get('glavweb_data_schema.validator'); + $rootDir = $this->getContainer()->getParameter('glavweb_data_schema.data_schema_dir'); + $nestingDepth = $this->getContainer()->getParameter('glavweb_data_schema.data_schema_max_nesting_depth'); + + $successful = false; + $path = $input->getArgument('path'); + + $output->writeln( + [ + 'DataSchema Validator', + '====================', + '', + ] + ); + + if ($path) { + try { + $dataSchemaValidator->validateFile($path, $nestingDepth); + + $successful = true; + $output->writeln('Validation successful'); + + } catch (\Exception $e) { + $output->writeln(sprintf('%s', $e->getMessage())); + $output->writeln('Validation failed'); + } + + } else { + $finder = new Finder(); + $finder->in($rootDir)->files(); + $totalCount = $finder->count(); + $errorsCount = 0; + + if (!$totalCount) { + $output->writeln(sprintf('Files not found in "%s" directory', $rootDir)); + + return 0; + } + + $output->writeln([sprintf('Validating %s configuration files...', $totalCount), '']); + + foreach ($finder as $file) { + try { + $dataSchemaValidator->validateFile($file->getRelativePathname(), $nestingDepth); + } catch (\Exception $e) { + $errorsCount++; + + $output->writeln( + [ + sprintf('%s:', $file->getRelativePathname()), + '--------------------', + sprintf('%s', $e->getMessage()), + '' + ] + ); + } + } + + if ($errorsCount) { + $output->writeln( + sprintf('Validation failed. %s configuration files have errors.', $errorsCount) + ); + + } else { + $successful = true; + $output->writeln('Validation successful'); + } + } + + return $successful ? 0 : 1; + } +} \ No newline at end of file diff --git a/Configuration/DataSchemaConfiguration.php b/Configuration/DataSchemaConfiguration.php new file mode 100755 index 0000000..b769005 --- /dev/null +++ b/Configuration/DataSchemaConfiguration.php @@ -0,0 +1,125 @@ +nestingDepth = $nestingDepth; + } + + /** + * @inheritDoc + */ + public function getConfigTreeBuilder() + { + $treeBuilder = new TreeBuilder(); + + $rootNode = $treeBuilder->root('schema'); + + $rootNode + ->children() + ->scalarNode('schema') + ->end() + ->enumNode('db_driver') + ->isRequired() + ->values(['orm']) + ->end() + ->scalarNode('class') + ->isRequired() + ->end() + ->arrayNode('roles') + ->beforeNormalization() + ->castToArray() + ->end() + ->end() + ->booleanNode('filter_null_values') + ->defaultTrue() + ->end() + ->arrayNode('query') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('selects') + ->useAttributeAsKey('name') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->append($this->addPropertiesNode($this->nestingDepth)) + ->end() + ; + + + return $treeBuilder; + } + + public function addPropertiesNode($depth) + { + $treeBuilder = new TreeBuilder(); + + $rootNode = $treeBuilder->root('properties'); + + if ($depth === 0) { + return $rootNode; + } + + $rootNode + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('schema') + ->defaultNull() + ->end() + ->scalarNode('class') + ->defaultNull() + ->end() + ->scalarNode('description') + ->defaultNull() + ->end() + ->scalarNode('discriminator') + ->defaultNull() + ->end() + ->booleanNode('filter_null_values') + ->defaultTrue() + ->end() + ->enumNode('join') + ->defaultValue('none') + ->values(['none', 'left', 'inner']) + ->end() + ->scalarNode('type') + ->defaultNull() + ->end() + ->scalarNode('source') + ->defaultNull() + ->end() + ->scalarNode('decode') + ->defaultNull() + ->end() + ->scalarNode('hidden') + ->defaultFalse() + ->end() + ->arrayNode('conditions') + ->scalarPrototype()->end() + ->end() + ->end() + ->append($this->addPropertiesNode(--$depth)) + ->end() + ; + + return $rootNode; + } +} \ No newline at end of file diff --git a/DataSchema/DataSchema.php b/DataSchema/DataSchema.php old mode 100644 new mode 100755 index b19ce31..a3743da --- a/DataSchema/DataSchema.php +++ b/DataSchema/DataSchema.php @@ -11,50 +11,54 @@ namespace Glavweb\DataSchemaBundle\DataSchema; -use Doctrine\Bundle\DoctrineBundle\Registry; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\MappingException; use Glavweb\DataSchemaBundle\DataSchema\Persister\PersisterFactory; use Glavweb\DataSchemaBundle\DataSchema\Persister\PersisterInterface; -use Glavweb\DataSchemaBundle\DataTransformer\DataTransformerRegistry; use Glavweb\DataSchemaBundle\DataTransformer\TransformEvent; use Glavweb\DataSchemaBundle\Exception\DataSchema\InvalidConfigurationException; +use Glavweb\DataSchemaBundle\Exception\DataTransformer\DataTransformerNotExists; use Glavweb\DataSchemaBundle\Hydrator\Doctrine\ObjectHydrator; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Glavweb\DataSchemaBundle\Service\DataSchemaFilter; +use Glavweb\DataSchemaBundle\Service\DataSchemaService; use Symfony\Component\Security\Core\User\UserInterface; /** * Class DataSchema * - * @author Andrey Nilov + * @author Andrey Nilov * @package Glavweb\DataSchemaBundle */ class DataSchema { + public const DEFAULTS = [ + 'schema' => null, + 'class' => null, + 'description' => null, + 'discriminator' => null, + 'filter_null_values' => true, + 'join' => 'none', + 'type' => null, + 'source' => null, + 'decode' => null, + 'hidden' => false, + 'conditions' => [], + 'roles' => [], + 'discriminatorColumnName' => null, + 'discriminatorMap' => [], + 'tableName' => null + ]; + /** * @var DataSchemaFactory */ private $dataSchemaFactory; - /** - * @var Registry - */ - private $doctrine; - - /** - * @var DataTransformerRegistry - */ - private $dataTransformerRegistry; - /** * @var PersisterInterface */ private $persister; - /** - * @var AuthorizationCheckerInterface - */ - private $authorizationChecker; - /** * @var Placeholder */ @@ -63,22 +67,17 @@ class DataSchema /** * @var array */ - private $configuration = []; + private $configuration; /** * @var array */ - private $scopeConfig = []; - - /** - * @var ClassMetadata[] - */ - private $classMetadataCache; + private $scopeConfig; /** - * @var bool + * @var int */ - private $withoutAssociations; + private $nestingDepth; /** * @var string|null @@ -90,212 +89,408 @@ class DataSchema */ private $objectHydrator; + /** + * @var DataSchemaService + */ + private $dataSchemaService; + + /** + * @var DataSchemaFilter + */ + private $dataSchemaFilter; + /** * DataSchema constructor. * - * @param DataSchemaFactory $dataSchemaFactory - * @param Registry $doctrine - * @param DataTransformerRegistry $dataTransformerRegistry - * @param PersisterFactory $persisterFactory - * @param AuthorizationCheckerInterface $authorizationChecker - * @param Placeholder $placeholder - * @param ObjectHydrator $objectHydrator - * @param array $configuration - * @param array $scopeConfig - * @param bool $withoutAssociations - * @param string|null $defaultHydratorMode + * @param DataSchemaFactory $dataSchemaFactory + * @param DataSchemaService $dataSchemaService + * @param DataSchemaFilter $dataSchemaFilter + * @param PersisterFactory $persisterFactory + * @param Placeholder $placeholder + * @param ObjectHydrator $objectHydrator + * @param array $configuration + * @param array|null $scopeConfig + * @param int|null $nestingDepth + * @param string|null $defaultHydratorMode + * @throws InvalidConfigurationException + * @throws MappingException */ - public function __construct( - DataSchemaFactory $dataSchemaFactory, - Registry $doctrine, - DataTransformerRegistry $dataTransformerRegistry, - PersisterFactory $persisterFactory, - AuthorizationCheckerInterface $authorizationChecker, - Placeholder $placeholder, - ObjectHydrator $objectHydrator, - array $configuration, - array $scopeConfig = null, - bool $withoutAssociations = false, - $defaultHydratorMode = null - ) { - $this->dataSchemaFactory = $dataSchemaFactory; - $this->doctrine = $doctrine; - $this->dataTransformerRegistry = $dataTransformerRegistry; - $this->authorizationChecker = $authorizationChecker; - $this->placeholder = $placeholder; - $this->objectHydrator = $objectHydrator; - $this->withoutAssociations = $withoutAssociations; - $this->defaultHydratorMode = $defaultHydratorMode; - - if (!isset($configuration['class'])) { - $configuration['class'] = null; - } - $class = $configuration['class']; + public function __construct(DataSchemaFactory $dataSchemaFactory, + DataSchemaService $dataSchemaService, + DataSchemaFilter $dataSchemaFilter, + PersisterFactory $persisterFactory, + Placeholder $placeholder, + ObjectHydrator $objectHydrator, + array $configuration, + array $scopeConfig = null, + int $nestingDepth = null, + string $defaultHydratorMode = null) + { + $this->dataSchemaFactory = $dataSchemaFactory; + $this->dataSchemaService = $dataSchemaService; + $this->dataSchemaFilter = $dataSchemaFilter; + $this->placeholder = $placeholder; + $this->objectHydrator = $objectHydrator; + $this->nestingDepth = $nestingDepth; + $this->defaultHydratorMode = $defaultHydratorMode; + + $this->persister = $persisterFactory->createPersister($configuration['db_driver'], $this); + $this->scopeConfig = $scopeConfig; - if (!isset($configuration['db_driver'])) { - throw new \RuntimeException('Option "db_driver" must be defined.'); - } + $this->dataSchemaService->startStopwatch('filter'); + + $configuration = $this->dataSchemaFilter->filter($configuration, $scopeConfig, $nestingDepth); + + $this->dataSchemaService->stopStopwatch('filter'); + $this->dataSchemaService->startStopwatch('prepareConfiguration'); + + $this->configuration = + $this->prepareConfiguration($configuration, $configuration['class'], $scopeConfig, $this->nestingDepth); + $this->dataSchemaService->stopStopwatch('prepareConfiguration'); - $this->persister = $persisterFactory->createPersister($configuration['db_driver'], $this); - $this->scopeConfig = $scopeConfig; - $this->configuration = $this->prepareConfiguration($configuration, $class, $scopeConfig); } /** - * @return array + * @param string $propertyName + * @return bool */ - public function getConfiguration() + public function hasProperty(string $propertyName): bool { - return $this->configuration; + return $this->getPropertyConfiguration($propertyName) !== null; + } + + /** + * @param string $propertyName + * @return bool + */ + public function hasPropertyInDb(string $propertyName): bool + { + $propertyConfiguration = $this->getPropertyConfiguration($propertyName); + + return $propertyConfiguration !== null && isset($propertyConfiguration['from_db']) + && $propertyConfiguration['from_db']; + } + + /** + * @param string $condition + * @param string $alias + * @param UserInterface|null $user + * @return string + */ + public function conditionPlaceholder(string $condition, string $alias, UserInterface $user = null): string + { + return $this->placeholder->condition($condition, $alias, $user); } /** - * @param array $data - * @param array $config - * @param array $scopeConfig - * @param string $parentClassName - * @param string $parentPropertyName - * @param array $defaultData + * @param array $configuration + * @param string|null $class + * @param array|null $scopeConfig + * @param int $nestingDepth * @return array - * @throws \Doctrine\ORM\Mapping\MappingException + * @throws InvalidConfigurationException + * @throws MappingException */ - public function getData(array $data, array $config = null, array $scopeConfig = null, $parentClassName = null, $parentPropertyName = null, $defaultData = []) + protected function prepareConfiguration(array $configuration, + ?string $class, + array $scopeConfig = null, + int $nestingDepth = 0): array { - if ($config === null) { - $config = $this->configuration; - } + $class = $class ?? $configuration['class'] ?? null; - if ($scopeConfig === null) { - $scopeConfig = $this->scopeConfig; - } + $configuration = array_replace(self::DEFAULTS, $configuration); + $configuration['class'] = $class; - if (!$data) { - return $defaultData; + if (!$this->dataSchemaFilter->isGranted($configuration['roles'])) { + return []; } - if (!isset($config['properties'])) { - return $defaultData; - } + // class + $classMetadata = $class ? $this->dataSchemaService->getClassMetadata($class) : null; + $identifierFieldNames = []; + $discriminatorMap = null; - $preparedData = []; + if ($classMetadata instanceof ClassMetadata) { + if ($classMetadata->subClasses) { + $configuration['discriminatorColumnName'] = $classMetadata->discriminatorColumn['name']; + $configuration['discriminatorMap'] = $classMetadata->discriminatorMap; + $discriminatorMap = $configuration['discriminatorMap']; + } - $class = $config['class']; - if ($config['discriminatorMap'] && isset($data[$config['discriminatorColumnName']])) { - $discriminator = $data[$config['discriminatorColumnName']]; - $class = $config['discriminatorMap'][$discriminator]; + $configuration['tableName'] = $classMetadata->getTableName(); + $identifierFieldNames = $classMetadata->getIdentifierFieldNames(); } + $configProperties = $configuration['properties'] ?? []; + $properties = []; - foreach ($config['properties'] as $propertyName => $propertyConfig) { - if (isset($propertyConfig['hidden']) && $propertyConfig['hidden'] == true) { - continue; + foreach ($identifierFieldNames as $idName) { + if (!array_key_exists($idName, $configProperties)) { + $configProperties[$idName] = array_merge(self::DEFAULTS, ['hidden' => true]); + $configuration['properties'][$idName] = $configProperties[$idName]; } + } - $propertyScopeConfig = null; - if ($scopeConfig !== null) { - if (!array_key_exists($propertyName, $scopeConfig)) { - continue; - } + foreach ($configProperties as $propertyName => $propertyConfig) { + $propertyScopeConfig = $scopeConfig[$propertyName] ?? null; + $schema = $propertyConfig['schema'] ?? null; + $isNested = $schema || !empty($propertyConfig['properties']); - $propertyScopeConfig = $scopeConfig[$propertyName] ?: []; + if ($schema) { + $propertyConfig = $this->getNestedDataSchemaConfiguration( + $schema, + $propertyConfig, + $nestingDepth - 1, + $propertyScopeConfig + ); } - if (array_key_exists($propertyName, $data)) { - $value = $data[$propertyName]; + $discriminator = $propertyConfig['discriminator'] ?? null; + $subClass = $discriminatorMap && $discriminator ? $discriminatorMap[$discriminator] ?? null : null; - if ($value === null) { - if (in_array($propertyConfig['type'], ['array', 'json_array'])) { - $value = []; + $propertyOwnerClassMetadata = + $subClass ? $this->dataSchemaService->getClassMetadata($subClass) : $classMetadata; - } elseif ($config['filter_null_values']) { - continue; - } + // set default description + if (empty($propertyConfig['description']) && $propertyOwnerClassMetadata instanceof ClassMetadata + && $propertyOwnerClassMetadata->hasField($propertyName)) { + + $fieldMapping = $propertyOwnerClassMetadata->getFieldMapping($propertyName); + $description = $fieldMapping['options']['comment'] ?? null; + + $propertyConfig['description'] = $description; + } + + if ($isNested) { + $propertyClass = $propertyConfig['class'] ?? null; + + if (!$propertyClass && $propertyOwnerClassMetadata instanceof ClassMetadata + && $propertyOwnerClassMetadata->hasAssociation($propertyName)) { + + $propertyClass = $propertyOwnerClassMetadata->getAssociationTargetClass($propertyName); } - } elseif (isset($propertyConfig['source']) && isset($data[$propertyConfig['source']])) { - $value = $data[$propertyConfig['source']]; + $propertyConfig = $this->prepareConfiguration( + $propertyConfig, + $propertyClass, + $propertyScopeConfig, + $nestingDepth - 1 + ); - // if property is nested object - } elseif (isset($propertyConfig['class']) && isset($propertyConfig['properties'])) { - $metadata = $this->getClassMetadata($class); - if (!$metadata->hasAssociation($propertyName)) { - continue; + if ($propertyConfig && $propertyOwnerClassMetadata instanceof ClassMetadata + && $propertyOwnerClassMetadata->hasAssociation($propertyName)) { + + $isCollection = $propertyOwnerClassMetadata->isCollectionValuedAssociation($propertyName); + + $propertyConfig['type'] = $isCollection ? 'collection' : 'entity'; } - $associationMapping = $metadata->getAssociationMapping($propertyName); - $databaseFields = self::getDatabaseFields($propertyConfig, $propertyScopeConfig); - $conditions = $propertyConfig['conditions']; + } else if ($propertyOwnerClassMetadata instanceof ClassMetadata) { + $propertyConfig['type'] = + $propertyConfig['type'] ?? $propertyOwnerClassMetadata->getTypeOfField($propertyName); - switch ($associationMapping['type']) { - case ClassMetadata::MANY_TO_MANY: - $orderByExpressions = isset($associationMapping['orderBy']) ? $associationMapping['orderBy'] : []; + $propertyConfig['from_db'] = $propertyOwnerClassMetadata->hasField($propertyName); - $modelData = $this->persister->getManyToManyData( - $associationMapping, - $data['id'], - $databaseFields, - $conditions, - $orderByExpressions - ); + $propertyConfig['field_db_name'] = + $propertyConfig['from_db'] ? $propertyOwnerClassMetadata->getColumnName($propertyName) : null; + } - $preparedData[$propertyName] = $this->getList( - $modelData, - $propertyConfig, - $propertyScopeConfig, - $class, - $propertyName - ); + $properties[$propertyName] = $propertyConfig; + } - break; + $configuration['properties'] = $properties; - case ClassMetadata::ONE_TO_MANY: - $orderByExpressions = isset($associationMapping['orderBy']) ? $associationMapping['orderBy'] : []; + return $configuration; + } - $modelData = $this->persister->getOneToManyData( - $associationMapping, - $data['id'], - $databaseFields, - $conditions, - $orderByExpressions - ); + /** + * @param mixed $value + * @param string $decodeString + * @param TransformEvent $transformEvent + * @return mixed + * @throws DataTransformerNotExists + */ + protected function decode($value, string $decodeString, TransformEvent $transformEvent) + { + $dataTransformerNames = $this->dataSchemaService->parseDecodeString($decodeString); - $preparedData[$propertyName] = $this->getList( - $modelData, - $propertyConfig, - $propertyScopeConfig, - $class, - $propertyName - ); + foreach ($dataTransformerNames as $dataTransformerName) { + $transformer = $this->dataSchemaService->getDataTransformer($dataTransformerName); + $value = $transformer->transform($value, $transformEvent); + } - break; + return $value; + } - case ClassMetadata::MANY_TO_ONE: - $modelData = $this->persister->getManyToOneData($associationMapping, $data['id'], $databaseFields, $conditions); - $preparedData[$propertyName] = $this->getData( - $modelData, - $propertyConfig, - $propertyScopeConfig, - $class, - $propertyName, - null - ); + /** + * @param array $data + * @param array $config + * @param array|null $scopeConfig + * @return array + * @throws MappingException|InvalidConfigurationException + */ + private function fetchMissingPropertiesRecursive(array $data, array $config, array $scopeConfig = null): array + { + $class = $this->getDataClassName($config, $data); + $metadata = $this->dataSchemaService->getClassMetadata($class); - break; + $result = $data + []; - case ClassMetadata::ONE_TO_ONE: - $modelData = $this->persister->getOneToOneData($associationMapping, $data['id'], $databaseFields, $conditions); - $preparedData[$propertyName] = $this->getData( - $modelData, - $propertyConfig, - $propertyScopeConfig, - $class, - $propertyName, - null - ); + foreach ($config['properties'] as $propertyName => $propertyConfig) { + $propertyScopeConfig = $scopeConfig[$propertyName] ?? []; + + if ($propertyConfig['class'] && $propertyConfig['properties']) { - break; + if (!$metadata->hasAssociation($propertyName)) { + continue; } - continue; + $value = null; + + if (array_key_exists($propertyName, $data)) { + $value = $data[$propertyName]; + + if (is_array($value)) { + if ($this->isIterablePropertyType($propertyConfig['type'])) { + $value = array_map( + function ($itemData) use ($propertyConfig, $propertyScopeConfig) { + return $this->fetchMissingPropertiesRecursive( + $itemData, + $propertyConfig, + $propertyScopeConfig + ); + }, + $value + ); + } else { + $value = + $this->fetchMissingPropertiesRecursive($value, $propertyConfig, $propertyScopeConfig); + } + } + } else { + $associationMapping = $metadata->getAssociationMapping($propertyName); + $databaseFields = $this->dataSchemaService->getDatabaseFields( + $propertyConfig, + $propertyScopeConfig + ); + $conditions = $propertyConfig['conditions']; + $orderByExpressions = $associationMapping['orderBy'] ?? []; + + switch ($associationMapping['type']) { + case ClassMetadata::MANY_TO_MANY: + + $modelData = $this->persister->getManyToManyData( + $associationMapping, + $data['id'], + $databaseFields, + $conditions, + $orderByExpressions + ); + + $value = array_map( + function ($itemData) use ($propertyConfig, $propertyScopeConfig) { + return $this->fetchMissingPropertiesRecursive( + $itemData, + $propertyConfig, + $propertyScopeConfig + ); + }, + $modelData + ); + + break; + + case ClassMetadata::ONE_TO_MANY: + $modelData = $this->persister->getOneToManyData( + $associationMapping, + $data['id'], + $databaseFields, + $conditions, + $orderByExpressions + ); + + $value = array_map( + function ($itemData) use ($propertyConfig, $propertyScopeConfig) { + return $this->fetchMissingPropertiesRecursive( + $itemData, + $propertyConfig, + $propertyScopeConfig + ); + }, + $modelData + ); + + break; + + case ClassMetadata::MANY_TO_ONE: + $modelData = $this->persister->getManyToOneData( + $associationMapping, + $data['id'], + $databaseFields, + $conditions + ); + + $value = $this->fetchMissingPropertiesRecursive( + $modelData, + $propertyConfig, + $propertyScopeConfig + ); + + break; + + case ClassMetadata::ONE_TO_ONE: + $modelData = $this->persister->getOneToOneData( + $associationMapping, + $data['id'], + $databaseFields, + $conditions + ); + + $value = $this->fetchMissingPropertiesRecursive( + $modelData, + $propertyConfig, + $propertyScopeConfig + ); + + break; + } + } + + $result[$propertyName] = $value; + + } + } + + return $result; + } + + /** + * @param array $data + * @param array $config + * @param array|null $scopeConfig + * @param string|null $parentClassName + * @param string|null $parentPropertyName + * @return array + * @throws DataTransformerNotExists + */ + private function modifyPropertiesRecursive(array $data, + array $config, + array $scopeConfig = null, + string $parentClassName = null, + string $parentPropertyName = null): array + { + $class = $this->getDataClassName($config, $data); + + $result = []; + + foreach ($config['properties'] as $propertyName => $propertyConfig) { + $value = null; + $propertyScopeConfig = $scopeConfig[$propertyName] ?? []; + + if (array_key_exists($propertyName, $data)) { + $value = $data[$propertyName]; + + } elseif ($propertyConfig['source'] && isset($data[$propertyConfig['source']])) { + $value = $data[$propertyConfig['source']]; } else { $value = null; @@ -303,42 +498,46 @@ public function getData(array $data, array $config = null, array $scopeConfig = if (is_array($value)) { if (!array_key_exists('type', $propertyConfig)) { - throw new \RuntimeException('Option "type" must be defined.'); + throw new \RuntimeException('Property "type" must be defined.'); } - if ($propertyConfig['type'] == 'entity') { + if ($propertyConfig['type'] === 'entity') { if (!$this->isOnlyNullInArray($value)) { - $preparedData[$propertyName] = $this->getData( + $value = $this->modifyPropertiesRecursive( $value, $propertyConfig, $propertyScopeConfig, $class, - $propertyName, - null + $propertyName ); - } else { - if (!$config['filter_null_values']) { - $preparedData[$propertyName] = null; - } + } else if (!$config['filter_null_values']) { + $value = null; } - continue; - - } elseif ($propertyConfig['type'] == 'collection') { - $preparedData[$propertyName] = $this->getList( - $value, - $propertyConfig, - $propertyScopeConfig, - $class, - $propertyName + } elseif ($propertyConfig['type'] === 'collection') { + $value = array_map( + function ($itemData) use ( + $propertyConfig, + $propertyScopeConfig, + $class, + $propertyName + ) { + return $this->modifyPropertiesRecursive( + $itemData, + $propertyConfig, + $propertyScopeConfig, + $class, + $propertyName + ); + }, + $value ); - continue; } } - if (isset($propertyConfig['decode'])) { + if ($propertyConfig['decode']) { $transformEvent = new TransformEvent( $class, $propertyName, @@ -349,6 +548,7 @@ public function getData(array $data, array $config = null, array $scopeConfig = $this->objectHydrator, $this->dataSchemaFactory ); + $value = $this->decode($value, $propertyConfig['decode'], $transformEvent); if (is_array($value) && $propertyScopeConfig) { @@ -359,371 +559,95 @@ public function getData(array $data, array $config = null, array $scopeConfig = } } - $preparedData[$propertyName] = $value; - } - - return $preparedData; - } + if ($value === null) { + if ($this->isIterablePropertyType($propertyConfig['type'])) { + $value = []; - /** - * @param array $list - * @param array $config - * @param array $scopeConfig - * @param string $parentClassName - * @param string $parentPropertyName - * @return array - */ - public function getList(array $list, array $config = null, array $scopeConfig = null, $parentClassName = null, $parentPropertyName = null) - { - if ($config === null) { - $config = $this->configuration; - } - - if ($scopeConfig === null) { - $scopeConfig = $this->scopeConfig; - } + } elseif ($config['filter_null_values']) { + continue; + } + } - foreach ($list as $key => $value) { - $list[$key] = $this->getData( - $value, - $config, - $scopeConfig, - $parentClassName, - $parentPropertyName, - null - ); + $result[$propertyName] = $value; } - return $list; + return $result; } /** - * @param array $configuration - * @param string $class - * @param array $scopeConfig * @return array - * @throws InvalidConfigurationException - * @throws \Doctrine\ORM\Mapping\MappingException */ - protected function prepareConfiguration(array $configuration, $class = null, array $scopeConfig = null) + public function getConfiguration(): array { - $classMetadata = $class ? $this->getClassMetadata($class) : null; - - // roles - if (!isset($configuration['roles'])) { - $configuration['roles'] = []; - } - - $isGranted = $this->isGranted($configuration['roles']); - if (!$isGranted) { - return []; - } - - // class - $configuration['class'] = $class; - $configuration['discriminatorColumnName'] = null; - $configuration['discriminatorMap'] = []; - $isSuperClass = false; - - if ($classMetadata instanceof ClassMetadata && $classMetadata->subClasses) { - $isSuperClass = true; - $configuration['discriminatorColumnName'] = $classMetadata->discriminatorColumn['name']; - $configuration['discriminatorMap'] = $classMetadata->discriminatorMap; - $configuration['tableName'] = $class; - } - - if ($classMetadata instanceof ClassMetadata) { - $configuration['tableName'] = $classMetadata->getTableName(); - } - - // condition - if (!isset($configuration['conditions'])) { - $configuration['conditions'] = []; - } - - // filter_null_values - if (!isset($configuration['filter_null_values'])) { - $configuration['filter_null_values'] = true; - } - - // inject properties - if (isset($configuration['schema']) && !$this->withoutAssociations) { - $configuration = $this->injectDataSchema($configuration['schema'], $configuration); - } - - $selects = $configuration['query']['selects'] ?? []; - - if (isset($configuration['properties'])) { - $properties = $configuration['properties']; - - // Set ids - $identifierFieldNames = $classMetadata instanceof ClassMetadata ? - $classMetadata->getIdentifierFieldNames() : - [] - ; - - foreach ($properties as $propertyName => $propertyConfig) { - foreach ($identifierFieldNames as $idName) { - if (!array_key_exists($idName, $properties)) { - $properties[$idName] = ['hidden' => true]; - $configuration['properties'][$idName] = $properties[$idName]; - } - } - } - - $validProperties = array_keys($selects); - - foreach ($properties as $propertyName => $propertyConfig) { - - // check superclass field requirements - if ($isSuperClass) { - $this->validateSuperclassProperty($configuration, $propertyName, $classMetadata, $validProperties); - } - - // Set default discriminator value for property - if (!isset($configuration['properties'][$propertyName]['discriminator'])) { - $configuration['properties'][$propertyName]['discriminator'] = null; - } - $propertyConfig = $configuration['properties'][$propertyName]; // update $propertyConfig - - // If has subclasses - $hasPropertyClassMetadata = - $propertyConfig['discriminator'] && - isset($configuration['discriminatorMap'][$propertyConfig['discriminator']]) - ; - - $propertyClassMetadata = $classMetadata; - if ($hasPropertyClassMetadata) { - $propertyClass = $configuration['discriminatorMap'][$propertyConfig['discriminator']]; - $propertyClassMetadata = $this->getClassMetadata($propertyClass); - } - - $isAssociationField = $propertyClassMetadata instanceof ClassMetadata ? - $propertyClassMetadata->hasAssociation($propertyName) : - false - ; - - $isRemove = - $scopeConfig !== null && - !in_array($propertyName, $identifierFieldNames) && - empty($propertyConfig['hidden']) && - !array_key_exists($propertyName, $scopeConfig) - ; - - // if without associations - $isRemove = $isRemove || ($this->withoutAssociations && $isAssociationField); - - if ($isRemove) { - unset($configuration['properties'][$propertyName]); - continue; - } - - // set default description - if (empty($propertyConfig['description']) && $propertyClassMetadata && $propertyClassMetadata->hasField($propertyName)) { - $fieldMapping = $propertyClassMetadata->getFieldMapping($propertyName); - $description = isset($fieldMapping['options']['comment']) ? $fieldMapping['options']['comment'] : null; - - $configuration['properties'][$propertyName]['description'] = $description; - } - - $isNestedField = - isset($propertyConfig['properties']) || - isset($propertyConfig['schema']) - ; - - if ($isNestedField) { - if ($propertyConfig['discriminator'] && isset($propertyConfig['join']) && $propertyConfig['join'] != 'none') { - throw new InvalidConfigurationException($configuration, 'The join type cannot be other than "none" if the discriminator is defined.'); - } - - $class = isset($propertyConfig['class']) ? - $propertyConfig['class'] : - ($propertyClassMetadata instanceof ClassMetadata ? - ($propertyClassMetadata->hasAssociation($propertyName) ? $propertyClassMetadata->getAssociationTargetClass($propertyName) : null) : - null - ) - ; - - $preparedConfiguration = $this->prepareConfiguration($propertyConfig, $class, $scopeConfig[$propertyName]); - $configuration['properties'][$propertyName] = $preparedConfiguration; - - // define type by association mapping - if ( - $preparedConfiguration && - $propertyClassMetadata instanceof ClassMetadata && - $propertyClassMetadata->hasAssociation($propertyName) - ) { - $associationMapping = $propertyClassMetadata->getAssociationMapping($propertyName); - $associationType = $associationMapping['type']; - - $type = in_array($associationType, [ClassMetadata::ONE_TO_MANY, ClassMetadata::MANY_TO_MANY]) ? 'collection' : 'entity'; - $configuration['properties'][$propertyName]['type'] = $type; - } - - } else { - if (!isset($propertyConfig['type']) && $propertyClassMetadata instanceof ClassMetadata) { - $configuration['properties'][$propertyName]['type'] = $propertyClassMetadata->getTypeOfField($propertyName); - } - - $configuration['properties'][$propertyName]['from_db'] = - $propertyClassMetadata instanceof ClassMetadata && - (bool)$propertyClassMetadata->getTypeOfField($propertyName) - ; - - $configuration['properties'][$propertyName]['field_db_name'] = $configuration['properties'][$propertyName]['from_db'] ? - $propertyClassMetadata->getColumnName($propertyName) : - null - ; - } - } - } - - return $configuration; + return $this->configuration; } /** - * Checks what subclasses properties configurations have discriminator definition - * - * @param array $configuration - * @param string $propertyName - * @param ClassMetadata|null $classMetadata - * @param array $validProperties + * @param array $data + * @param array|null $config + * @param array|null $scopeConfig + * @param array|null $defaultData + * @return array * @throws InvalidConfigurationException + * @throws MappingException|DataTransformerNotExists */ - protected function validateSuperclassProperty(array $configuration, string $propertyName, ?ClassMetadata $classMetadata, array $validProperties): void + public function getData(array $data, + array $config = null, + array $scopeConfig = null, + ?array $defaultData = []): array { - if (!isset($configuration['properties'])) { - throw new \InvalidArgumentException('Configuration should have properties property'); - } + $this->dataSchemaService->startStopwatch('getData'); - $properties = $configuration['properties']; - $valid = false; - $depth = 0; - $propertiesStack = []; - - do { - $propertyConfig = $properties[$propertyName]; - $propertiesStack[] = $propertyName; - - if (in_array($propertyName, $validProperties)) { - $valid = true; - break; - } + $config = $config ?? $this->configuration; + $scopeConfig = $scopeConfig ?? $this->scopeConfig; - $isClassField = $classMetadata instanceof ClassMetadata - && ( - $classMetadata->hasField($propertyName) - || $classMetadata->hasAssociation($propertyName) - ); - - if (isset($propertyConfig['discriminator']) || $isClassField) { - $validProperties[] = $propertyName; - $valid = true; - break; - } - - if (++$depth > 10) { - $propertiesStackString = implode(' > ', $propertiesStack); - throw new InvalidConfigurationException($configuration, "Maximum depth exceeded \"$propertiesStackString\""); - } - - } while ($propertyName = $propertyConfig['source'] ?? null); - - if (!$valid) { - throw new InvalidConfigurationException($configuration, - "Property \"$propertyName\" should have \"discriminator\" definition" - ); + if ($config !== $this->configuration || $scopeConfig !== $this->scopeConfig) { + $config = $this->dataSchemaFilter->filter($config, $scopeConfig, $this->nestingDepth); } - } - protected function getScopedData(array $data, array $scope): array - { - $scopedData = []; - - foreach ($data as $key => $value) { - if (array_key_exists($key, $scope)) { - if (is_array($value) && $scope[$key]) { - $scopedData[$key] = $this->getScopedData($value, $scope[$key]); - - } else { - $scopedData[$key] = $value; - } - } + if (!$data) { + return $defaultData; } - return $scopedData; - } - - /** - * @param string $class - * @return ClassMetadata - */ - protected function getClassMetadata($class) - { - if (!isset($this->classMetadataCache[$class])) { - $classMetadata = $this->doctrine->getManager()->getClassMetadata($class); - - $this->classMetadataCache[$class] = $classMetadata; + if (!$config['properties']) { + return $defaultData; } - return $this->classMetadataCache[$class]; - } - - /** - * @param mixed $value - * @param string $decodeString - * @param TransformEvent $transformEvent - * @return mixed - */ - protected function decode($value, $decodeString, TransformEvent $transformEvent) - { - $dataTransformerNames = explode('|', $decodeString); - $dataTransformerNames = array_map('trim', $dataTransformerNames); + $fetchedData = $this->fetchMissingPropertiesRecursive($data, $config, $scopeConfig); - foreach ($dataTransformerNames as $dataTransformerName) { - $hasDataTransformer = $this->dataTransformerRegistry->has($dataTransformerName); + $modifiedData = $this->modifyPropertiesRecursive($fetchedData, $config, $scopeConfig); - if ($hasDataTransformer) { - $transformer = $this->dataTransformerRegistry->get($dataTransformerName); - $value = $transformer->transform($value, $transformEvent); - } - } + $this->dataSchemaService->stopStopwatch('getData'); - return $value; + return $modifiedData; } /** - * @param array $entityConfig - * @param array $scopeConfig + * @param array $list + * @param array|null $config + * @param array|null $scopeConfig * @return array + * @throws MappingException|InvalidConfigurationException + * @throws DataTransformerNotExists */ - public static function getDatabaseFields(array $entityConfig, array $scopeConfig = null) + public function getList(array $list, array $config = null, array $scopeConfig = null): array { - $properties = $entityConfig['properties']; - $entityClass = $entityConfig['class']; - $discriminatorMap = $entityConfig['discriminatorMap'] ?? null; - $databaseFields = []; - foreach ($properties as $propertyName => $propertyData) { - if (isset($propertyData['discriminator']) && $discriminatorMap && $discriminatorMap[$propertyData['discriminator']] !== $entityClass) { - continue; - } - if ($scopeConfig && !array_key_exists($propertyName, $scopeConfig)) { - continue; - } + $this->dataSchemaService->startStopwatch('getList'); - while (isset($propertyData['source']) && $propertyData['source'] !== $propertyName) { - $propertyName = $propertyData['source']; - $propertyData = $properties[$propertyName]; - } - - $isValid = (isset($propertyData['from_db']) && $propertyData['from_db']); + foreach ($list as $key => $value) { + $list[$key] = $this->getData( + $value, + $config, + $scopeConfig, + null + ); - if ($isValid && !in_array($propertyName, $databaseFields)) { - $databaseFields[] = $propertyName; - } + $this->dataSchemaService->lapStopwatch('getList'); } - return $databaseFields; + $this->dataSchemaService->stopStopwatch('getList'); + + return $list; } /** @@ -731,36 +655,14 @@ public static function getDatabaseFields(array $entityConfig, array $scopeConfig */ public function getQuerySelects(): array { - return isset($this->configuration['query']['selects']) ? $this->configuration['query']['selects'] : []; - } - - /** - * @param string $propertyName - * @return bool - */ - public function hasProperty(string $propertyName): bool - { - return $this->getPropertyConfiguration($propertyName) !== null; - } - - /** - * @param string $propertyName - * @return bool - */ - public function hasPropertyInDb(string $propertyName): bool - { - $propertyConfiguration = $this->getPropertyConfiguration($propertyName); - - return $propertyConfiguration !== null && - isset($propertyConfiguration['from_db']) && $propertyConfiguration['from_db'] - ; + return $this->configuration['query']['selects'] ?? []; } /** * @param string $propertyName * @return array|null */ - public function getPropertyConfiguration(string $propertyName):? array + public function getPropertyConfiguration(string $propertyName): ?array { $propertyConfiguration = $this->configuration; @@ -781,70 +683,64 @@ public function getPropertyConfiguration(string $propertyName):? array */ public function getHydrationMode() { - return isset($this->configuration['hydration_mode']) ? $this->configuration['hydration_mode'] : $this->defaultHydratorMode; + return $this->configuration['hydration_mode'] ?? $this->defaultHydratorMode; } /** - * @param array $roles - * @return bool + * @param array $data + * @param array $scope + * @return array */ - protected function isGranted(array $roles) + protected function getScopedData(array $data, array $scope): array { - if (empty($roles)) { - return true; - } + $scopedData = []; + + foreach ($data as $key => $value) { + if (array_key_exists($key, $scope)) { + if (is_array($value) && $scope[$key]) { + $scopedData[$key] = $this->getScopedData($value, $scope[$key]); - foreach ($roles as $role) { - if ($this->authorizationChecker->isGranted($role)) { - return true; + } else { + $scopedData[$key] = $value; + } } } - return false; + return $scopedData; } /** - * @param string $condition - * @param string $alias - * @param UserInterface $user - * @return string + * @param string|null $type + * @return bool */ - public function conditionPlaceholder($condition, $alias, UserInterface $user = null) + private function isIterablePropertyType(?string $type): bool { - return $this->placeholder->condition($condition, $alias, $user); + return in_array($type, ['array', 'json_array', 'collection']); } /** - * @param string $dataSchemaFile - * @param array $configuration + * @param string $dataSchemaFile + * @param array $configuration + * @param int $nestingDepth + * @param array|null $scopeConfig * @return array + * @throws InvalidConfigurationException + * @throws MappingException */ - private function injectDataSchema($dataSchemaFile, array $configuration) + private function getNestedDataSchemaConfiguration(string $dataSchemaFile, + array $configuration, + int $nestingDepth, + array $scopeConfig = null): array { - $dataSchema = $this->dataSchemaFactory->createDataSchema($dataSchemaFile, null, true); - $injectedConfiguration = $dataSchema->getConfiguration(); - - // inject properties (save source property order) - if (isset($injectedConfiguration['properties'])) { - $injectedProperties = $injectedConfiguration['properties']; - - $properties = isset($configuration['properties']) ? $configuration['properties'] : []; - foreach ($properties as $propertyName => $propertyConfig) { - $injectedProperties[$propertyName] = $propertyConfig; - } - $configuration['properties'] = $injectedProperties; - unset($injectedConfiguration['properties']); - } - - // inject rest configuration parameters - foreach ($injectedConfiguration as $key => $value) { - if (!array_key_exists($key, $configuration)) { - $configuration[$key] = $value; - } - } + $dataSchema = $this->dataSchemaFactory->createNestedDataSchema( + $dataSchemaFile, + $configuration, + $scopeConfig, + $nestingDepth + ); - return $configuration; + return $dataSchema->getConfiguration(); } /** @@ -861,4 +757,21 @@ private function isOnlyNullInArray(array $array): bool return true; } + + /** + * @param array $config + * @param array $data + * @return string + */ + private function getDataClassName(array $config, array $data): string + { + $class = $config['class']; + + if ($config['discriminatorMap'] && isset($data[$config['discriminatorColumnName']])) { + $discriminator = $data[$config['discriminatorColumnName']]; + $class = $config['discriminatorMap'][$discriminator]; + } + + return $class; + } } diff --git a/DataSchema/DataSchemaFactory.php b/DataSchema/DataSchemaFactory.php old mode 100644 new mode 100755 index b126bc0..a0e2c95 --- a/DataSchema/DataSchemaFactory.php +++ b/DataSchema/DataSchemaFactory.php @@ -11,154 +11,158 @@ namespace Glavweb\DataSchemaBundle\DataSchema; -use Doctrine\Bundle\DoctrineBundle\Registry; +use Doctrine\ORM\Mapping\MappingException; use Glavweb\DataSchemaBundle\DataSchema\Persister\PersisterFactory; -use Glavweb\DataSchemaBundle\DataTransformer\DataTransformerRegistry; +use Glavweb\DataSchemaBundle\Exception\DataSchema\InvalidConfigurationException; use Glavweb\DataSchemaBundle\Hydrator\Doctrine\ObjectHydrator; -use Glavweb\DataSchemaBundle\Loader\Yaml\DataSchemaYamlLoader; -use Glavweb\DataSchemaBundle\Loader\Yaml\ScopeYamlLoader; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Glavweb\DataSchemaBundle\Service\DataSchemaFilter; +use Glavweb\DataSchemaBundle\Service\DataSchemaService; +use Glavweb\DataSchemaBundle\Service\DataSchemaValidator; +use Glavweb\DataSchemaBundle\Util\Utils; /** * Class DataSchemaFactory * - * @author Andrey Nilov + * @author Andrey Nilov * @package Glavweb\DataSchemaBundle */ class DataSchemaFactory { - /** - * @var Registry - */ - private $doctrine; /** - * @var DataTransformerRegistry + * @var PersisterFactory */ - private $dataTransformerRegistry; + private $persisterFactory; /** - * @var PersisterFactory + * @var string */ - private $persisterFactory; + private $defaultHydratorMode; /** - * @var AuthorizationCheckerInterface + * @var Placeholder */ - private $authorizationChecker; + private $placeholder; /** - * @var string + * @var ObjectHydrator */ - private $dataSchemaDir; + private $objectHydrator; /** - * @var string + * @var int */ - private $scopeDir; + private $nestingDepth; /** - * @var string + * @var DataSchemaService */ - private $defaultHydratorMode; + private $dataSchemaService; /** - * @var Placeholder + * @var DataSchemaFilter */ - private $placeholder; + private $dataSchemaFilter; /** - * @var ObjectHydrator + * @var DataSchemaValidator */ - private $objectHydrator; + private $dataSchemaValidator; /** * DataSchema constructor. * - * @param Registry $doctrine - * @param DataTransformerRegistry $dataTransformerRegistry - * @param PersisterFactory $persisterFactory - * @param AuthorizationCheckerInterface $authorizationChecker - * @param Placeholder $placeholder - * @param ObjectHydrator $objectHydrator - * @param string $dataSchemaDir - * @param string $scopeDir - * @param string $defaultHydratorMode + * @param DataSchemaService $dataSchemaService + * @param DataSchemaFilter $dataSchemaFilter + * @param DataSchemaValidator $dataSchemaValidator + * @param PersisterFactory $persisterFactory + * @param Placeholder $placeholder + * @param ObjectHydrator $objectHydrator + * @param int $nestingDepth + * @param string|null $defaultHydratorMode */ - public function __construct( - Registry $doctrine, - DataTransformerRegistry $dataTransformerRegistry, - PersisterFactory $persisterFactory, - AuthorizationCheckerInterface $authorizationChecker, - Placeholder $placeholder, - ObjectHydrator $objectHydrator, - string $dataSchemaDir, - string $scopeDir, - $defaultHydratorMode = null - ){ - $this->doctrine = $doctrine; - $this->dataTransformerRegistry = $dataTransformerRegistry; - $this->persisterFactory = $persisterFactory; - $this->authorizationChecker = $authorizationChecker; - $this->placeholder = $placeholder; - $this->objectHydrator = $objectHydrator; - $this->dataSchemaDir = $dataSchemaDir; - $this->scopeDir = $scopeDir; - $this->defaultHydratorMode = $defaultHydratorMode; + public function __construct(DataSchemaService $dataSchemaService, + DataSchemaFilter $dataSchemaFilter, + DataSchemaValidator $dataSchemaValidator, + PersisterFactory $persisterFactory, + Placeholder $placeholder, + ObjectHydrator $objectHydrator, + int $nestingDepth, + string $defaultHydratorMode = null) + { + $this->dataSchemaService = $dataSchemaService; + $this->dataSchemaFilter = $dataSchemaFilter; + $this->dataSchemaValidator = $dataSchemaValidator; + $this->persisterFactory = $persisterFactory; + $this->placeholder = $placeholder; + $this->objectHydrator = $objectHydrator; + $this->nestingDepth = $nestingDepth; + $this->defaultHydratorMode = $defaultHydratorMode; } /** - * @param string $dataSchemaFile - * @param string $scopeFile - * @param bool $withoutInheritance + * @param string $dataSchemaFile + * @param string|null $scopeFile * @return DataSchema + * @throws InvalidConfigurationException|MappingException */ - public function createDataSchema($dataSchemaFile, $scopeFile = null, $withoutInheritance = false) + public function createDataSchema(string $dataSchemaFile, string $scopeFile = null): DataSchema { - $dataSchemaConfig = $this->getDataSchemaConfig($dataSchemaFile); + $dataSchemaConfig = $this->dataSchemaService->getConfigurationFromFile($dataSchemaFile); + + $this->dataSchemaValidator->validate($dataSchemaConfig, $this->nestingDepth); $scopeConfig = null; if ($scopeFile) { - $scopeConfig = $this->getScopeConfig($scopeFile); + $scopeConfig = $this->dataSchemaService->loadScopeConfiguration($scopeFile); } return new DataSchema( $this, - $this->doctrine, - $this->dataTransformerRegistry, + $this->dataSchemaService, + $this->dataSchemaFilter, $this->persisterFactory, - $this->authorizationChecker, $this->placeholder, $this->objectHydrator, $dataSchemaConfig, $scopeConfig, - $withoutInheritance, + $this->nestingDepth, $this->defaultHydratorMode ); } /** - * @param string $dataSchemaFile - * @return array + * @param string $dataSchemaFile + * @param array $configuration + * @param null $scopeConfig + * @param int|null $nestingDepth + * @return DataSchema + * @throws MappingException + * @throws InvalidConfigurationException */ - public function getDataSchemaConfig($dataSchemaFile) + public function createNestedDataSchema(string $dataSchemaFile, + array $configuration, + $scopeConfig = null, + int $nestingDepth = null): DataSchema { - $dataSchemaLoader = new DataSchemaYamlLoader(new FileLocator($this->dataSchemaDir)); - $dataSchemaLoader->load($dataSchemaFile); + $dataSchemaConfig = $this->dataSchemaService->getConfigurationFromFile($dataSchemaFile); - return $dataSchemaLoader->getConfiguration(); - } + $this->dataSchemaValidator->validate($dataSchemaConfig, $this->nestingDepth); - /** - * @param string $scopeFile - * @return array - */ - private function getScopeConfig($scopeFile) - { - $scopeLoader = new ScopeYamlLoader(new FileLocator($this->scopeDir)); - $scopeLoader->load($scopeFile); + $mergedConfig = Utils::arrayDeepMerge($dataSchemaConfig, $configuration); - return $scopeLoader->getConfiguration(); + return new DataSchema( + $this, + $this->dataSchemaService, + $this->dataSchemaFilter, + $this->persisterFactory, + $this->placeholder, + $this->objectHydrator, + $mergedConfig, + $scopeConfig, + $nestingDepth, + $this->defaultHydratorMode + ); } + } \ No newline at end of file diff --git a/DataSchema/Persister/EntityPersister.php b/DataSchema/Persister/EntityPersister.php old mode 100644 new mode 100755 index 8d26e96..be563c7 --- a/DataSchema/Persister/EntityPersister.php +++ b/DataSchema/Persister/EntityPersister.php @@ -14,13 +14,14 @@ use Doctrine\Bundle\DoctrineBundle\Registry; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\Expr\Join; use Glavweb\DataSchemaBundle\DataSchema\DataSchema; use Glavweb\DataSchemaBundle\Exception\Persister\InvalidQueryException; /** * Class EntityPersister * - * @author Andrey Nilov + * @author Andrey Nilov * @package Glavweb\DataSchemaBundle */ class EntityPersister implements PersisterInterface @@ -61,10 +62,11 @@ public function __construct(Registry $doctrine, DataSchema $dataSchema, $hydrati * @param array $conditions * @param array $orderByExpressions * @return array + * @throws InvalidQueryException */ public function getManyToManyData(array $associationMapping, $id, array $databaseFields, array $conditions = [], array $orderByExpressions = []) { - $query = $this->getQuery($associationMapping, $id, $databaseFields, $conditions, $orderByExpressions); + $query = $this->getQuery($associationMapping, $id, false, $databaseFields, $conditions, $orderByExpressions); return $query->getArrayResult(); } @@ -76,10 +78,11 @@ public function getManyToManyData(array $associationMapping, $id, array $databas * @param array $conditions * @param array $orderByExpressions * @return array + * @throws InvalidQueryException */ public function getOneToManyData(array $associationMapping, $id, array $databaseFields, array $conditions = [], array $orderByExpressions = []) { - $query = $this->getQuery($associationMapping, $id, $databaseFields, $conditions, $orderByExpressions); + $query = $this->getQuery($associationMapping, $id, false, $databaseFields, $conditions, $orderByExpressions); return $query->getArrayResult(); } @@ -90,10 +93,12 @@ public function getOneToManyData(array $associationMapping, $id, array $database * @param array $databaseFields * @param array $conditions * @return array + * @throws InvalidQueryException + * @throws \Doctrine\ORM\NonUniqueResultException */ public function getManyToOneData(array $associationMapping, $id, array $databaseFields, array $conditions = []) { - $query = $this->getQuery($associationMapping, $id, $databaseFields, $conditions); + $query = $this->getQuery($associationMapping, $id, true, $databaseFields, $conditions); return (array)$query->getOneOrNullResult(); } @@ -104,10 +109,12 @@ public function getManyToOneData(array $associationMapping, $id, array $database * @param array $databaseFields * @param array $conditions * @return array + * @throws InvalidQueryException + * @throws \Doctrine\ORM\NonUniqueResultException */ public function getOneToOneData(array $associationMapping, $id, array $databaseFields, array $conditions = []) { - $query = $this->getQuery($associationMapping, $id, $databaseFields, $conditions); + $query = $this->getQuery($associationMapping, $id, true, $databaseFields, $conditions); return (array)$query->getOneOrNullResult(); } @@ -115,36 +122,56 @@ public function getOneToOneData(array $associationMapping, $id, array $databaseF /** * @param array $associationMapping * @param mixed $id + * @param bool $single * @param array $databaseFields * @param array $conditions * @param array $orderByExpressions * @return Query * @throws InvalidQueryException */ - protected function getQuery(array $associationMapping, $id, array $databaseFields, array $conditions = [], array $orderByExpressions = []) + protected function getQuery(array $associationMapping, $id, bool $single, array $databaseFields, array $conditions = [], array $orderByExpressions = []) { /** @var EntityManager $em */ $em = $this->doctrine->getManager(); $targetClass = $associationMapping['targetEntity']; $joinField = $associationMapping['isOwningSide'] ? $associationMapping['inversedBy'] : $associationMapping['mappedBy']; - $targetAlias = uniqid('t'); - $sourceAlias = uniqid('s'); + $targetAlias = uniqid('t', false); + $sourceAlias = uniqid('s', false); + $qb = $em->createQueryBuilder(); if (!$joinField) { - throw new InvalidQueryException(sprintf('The join filed part cannot be defined. May be you need configure association mapping for classes "%s" and "%s".', - $associationMapping['sourceEntity'], - $targetClass - )); - } + $sourceClass = $associationMapping['sourceEntity']; + $sourceField = $associationMapping['fieldName']; + $associationOperator = $single ? '=' : 'MEMBER OF'; + + if (!$sourceField) { + throw new InvalidQueryException( + sprintf( + 'The join filed part cannot be defined. May be you need configure association mapping for classes "%s" and "%s".', + $associationMapping['sourceEntity'], + $targetClass + ) + ); + } - $qb = $em->createQueryBuilder() - ->select(sprintf('PARTIAL %s.{%s}', $targetAlias, implode(',', $databaseFields))) - ->from($targetClass, $targetAlias) - ->join(sprintf('%s.%s', $targetAlias, $joinField), $sourceAlias) - ->where($sourceAlias . '.id = :sourceId') - ->setParameter('sourceId', $id) - ; + $qb + ->select(sprintf('PARTIAL %s.{%s}', $targetAlias, implode(',', $databaseFields))) + ->from($targetClass, $targetAlias) + ->join($sourceClass, $sourceAlias, Join::WITH, + sprintf('%s %s %s.%s', $targetAlias, $associationOperator, $sourceAlias, $sourceField) + ) + ->where($sourceAlias . '.id = :sourceId') + ->setParameter('sourceId', $id); + + } else { + $qb + ->select(sprintf('PARTIAL %s.{%s}', $targetAlias, implode(',', $databaseFields))) + ->from($targetClass, $targetAlias) + ->join(sprintf('%s.%s', $targetAlias, $joinField), $sourceAlias) + ->where($sourceAlias . '.id = :sourceId') + ->setParameter('sourceId', $id); + } foreach ($conditions as $condition) { $preparedCondition = $this->dataSchema->conditionPlaceholder($condition, $targetAlias); diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php old mode 100644 new mode 100755 index 7947ce7..30705c6 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -40,6 +40,7 @@ public function getConfigTreeBuilder() ->arrayNode('data_schema') ->children() ->scalarNode('dir')->end() + ->integerNode('max_nesting_depth')->defaultValue(10)->min(1)->end() ->end() ->end() ->arrayNode('scope') diff --git a/DependencyInjection/GlavwebDataSchemaExtension.php b/DependencyInjection/GlavwebDataSchemaExtension.php old mode 100644 new mode 100755 index 723fdfd..7a956da --- a/DependencyInjection/GlavwebDataSchemaExtension.php +++ b/DependencyInjection/GlavwebDataSchemaExtension.php @@ -11,13 +11,13 @@ namespace Glavweb\DataSchemaBundle\DependencyInjection; -use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; /** - * Class GlavwebDatagridExtension + * Class GlavwebDataSchemaExtension * * This is the class that loads and manages your bundle configuration * @@ -41,6 +41,7 @@ public function load(array $configs, ContainerBuilder $container) $container->setParameter('glavweb_data_schema.default_hydrator_mode', $config['default_hydrator_mode']); $container->setParameter('glavweb_data_schema.data_schema_dir', $config['data_schema']['dir']); + $container->setParameter('glavweb_data_schema.data_schema_max_nesting_depth', $config['data_schema']['max_nesting_depth']); $container->setParameter('glavweb_data_schema.scope_dir', $config['scope']['dir']); } } diff --git a/Exception/DataSchema/InvalidConfigurationException.php b/Exception/DataSchema/InvalidConfigurationException.php old mode 100644 new mode 100755 index 476d015..4fd83b4 --- a/Exception/DataSchema/InvalidConfigurationException.php +++ b/Exception/DataSchema/InvalidConfigurationException.php @@ -25,17 +25,17 @@ class InvalidConfigurationException extends Exception /** * InvalidConfigurationException constructor. * - * @param array $configuration - * @param string $message - * @param int $code + * @param array|null $configuration + * @param string $message + * @param int $code * @param Throwable|null $previous */ public function __construct(array $configuration = null, $message = "", $code = 0, Throwable $previous = null) { - $className = $configuration['class'] ?? null; + $schemaName = $configuration['schema'] ?? null; - if ($className) { - $message = "$className: $message"; + if ($schemaName) { + $message = "Schema \"$schemaName\": $message"; } parent::__construct($message, $code, $previous); diff --git a/Exception/DataSchema/InvalidConfigurationPropertyException.php b/Exception/DataSchema/InvalidConfigurationPropertyException.php new file mode 100644 index 0000000..72ff076 --- /dev/null +++ b/Exception/DataSchema/InvalidConfigurationPropertyException.php @@ -0,0 +1,21 @@ + $values) { - if (in_array($namespace, array('imports'))) { + if ($namespace === 'imports') { continue; } diff --git a/Loader/Yaml/ScopeYamlLoader.php b/Loader/Yaml/ScopeYamlLoader.php old mode 100644 new mode 100755 index 0bb0a92..122766f --- a/Loader/Yaml/ScopeYamlLoader.php +++ b/Loader/Yaml/ScopeYamlLoader.php @@ -108,7 +108,7 @@ private function parseImports($content, $file) private function loadConfiguration(array $content) { foreach ($content as $namespace => $values) { - if (in_array($namespace, array('imports'))) { + if ($namespace === 'imports') { continue; } diff --git a/Resources/config/services.yml b/Resources/config/services.yml old mode 100644 new mode 100755 index b3b145a..86f4f95 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -1,16 +1,39 @@ services: - glavweb_data_schema.data_schema_factory: - class: Glavweb\DataSchemaBundle\DataSchema\DataSchemaFactory + glavweb_data_schema.service: + class: Glavweb\DataSchemaBundle\Service\DataSchemaService public: true arguments: - "@doctrine" - "@glavweb_data_schema.data_transformer_registry" - - "@glavweb_data_schema.persister_factory" + - "%glavweb_data_schema.data_schema_dir%" + - "%glavweb_data_schema.scope_dir%" + - "%glavweb_data_schema.data_schema_max_nesting_depth%" + - "@?debug.stopwatch" + + glavweb_data_schema.filter: + class: Glavweb\DataSchemaBundle\Service\DataSchemaFilter + public: true + arguments: + - "@glavweb_data_schema.service" - "@security.authorization_checker" + + glavweb_data_schema.validator: + class: Glavweb\DataSchemaBundle\Service\DataSchemaValidator + public: true + arguments: + - "@glavweb_data_schema.service" + + glavweb_data_schema.data_schema_factory: + class: Glavweb\DataSchemaBundle\DataSchema\DataSchemaFactory + public: true + arguments: + - "@glavweb_data_schema.service" + - "@glavweb_data_schema.filter" + - "@glavweb_data_schema.validator" + - "@glavweb_data_schema.persister_factory" - "@glavweb_data_schema.placeholder" - "@glavweb_data_schema.orm_object_hydrator" - - "%glavweb_data_schema.data_schema_dir%" - - "%glavweb_data_schema.scope_dir%" + - "%glavweb_data_schema.data_schema_max_nesting_depth%" - "%glavweb_data_schema.default_hydrator_mode%" glavweb_data_schema.placeholder: @@ -37,6 +60,11 @@ services: tags: - { name: console.command, command: "glavweb:data-schema" } + glavweb_data_schema.validate_data_schema_command: + class: Glavweb\DataSchemaBundle\Command\ValidateDataSchemaCommand + tags: + - { name: console.command, command: "glavweb:data-schema:validate" } + glavweb_data_schema.generate_scope_command: class: Glavweb\DataSchemaBundle\Command\GenerateScopeCommand tags: diff --git a/Service/DataSchemaFilter.php b/Service/DataSchemaFilter.php new file mode 100644 index 0000000..bf0ce91 --- /dev/null +++ b/Service/DataSchemaFilter.php @@ -0,0 +1,147 @@ +dataSchemaService = $dataSchemaService; + $this->authorizationChecker = $authorizationChecker; + } + + /** + * @param array $config + * @param array|null $scopeConfig + * @param int $nestingDepth + * @return array + * @throws InvalidConfigurationException + */ + public function filter(array $config, array $scopeConfig = null, int $nestingDepth = 0): array + { + if (!$this->isGranted($config['roles'] ?? [])) { + return []; + } + + $configProperties = $config['properties']; + $result = $config + []; + + if ($configProperties) { + $class = $config['class'] ?? null; + $classMetadata = $class ? $this->dataSchemaService->getClassMetadata($class) : null; + $identifierFieldNames = + $classMetadata instanceof ClassMetadata ? $classMetadata->getIdentifierFieldNames() : []; + + $properties = []; + + foreach ($configProperties as $propertyName => $propertyConfig) { + $propertyScopeConfig = $scopeConfig[$propertyName] ?? null; + $isNested = $propertyConfig['schema'] || $propertyConfig['properties']; + $isIdentifier = in_array($propertyName, $identifierFieldNames, true); + $isHidden = $propertyConfig['hidden'] ?? false; + $isInScope = array_key_exists($propertyName, $scopeConfig) || !$scopeConfig; + + if ((!$isInScope && !$isHidden && !$isIdentifier) || (!$isInScope && $isNested && $nestingDepth <= 0)) { + continue; + } + + $source = $propertyConfig['source'] ?? null; + + if ($source) { + $propertySourcesStack = $this->dataSchemaService->getPropertySourcesStack($config, $propertyName); + foreach ($propertySourcesStack as [$sourcePropertyName, $sourcePropertyConfig]) { + if (!isset($properties[$sourcePropertyName]) && $sourcePropertyConfig) { + $sourcePropertyScopeConfig = $scopeConfig[$sourcePropertyName] ?? null; + $sourcePropertyConfig = $this->filterProperty( + $sourcePropertyConfig, + $sourcePropertyScopeConfig, + $nestingDepth - 1 + ); + + if ($sourcePropertyConfig) { + $properties[$sourcePropertyName] = $sourcePropertyConfig; + } + } + } + } + + $propertyConfig = $this->filterProperty( + $propertyConfig, + $propertyScopeConfig, + $nestingDepth - 1 + ); + + if ($propertyConfig) { + $properties[$propertyName] = $propertyConfig; + } + } + + $result['properties'] = $properties; + } + + return $result; + } + + /** + * @param array $roles + * @return bool + */ + public function isGranted(array $roles): bool + { + if (empty($roles)) { + return true; + } + + foreach ($roles as $role) { + if ($this->authorizationChecker->isGranted($role)) { + return true; + } + } + + return false; + } + + /** + * @param array $propertyConfig + * @param array|null $scopeConfig + * @param int $nestingDepth + * @return array|null + * @throws InvalidConfigurationException + */ + private function filterProperty(array $propertyConfig, + ?array $scopeConfig, + int $nestingDepth): ?array + { + $isNested = $propertyConfig['schema'] || $propertyConfig['properties']; + $sourcePropertyScopeConfig = $scopeConfig ?? null; + + if ($isNested) { + return $this->filter( + $propertyConfig, + $sourcePropertyScopeConfig, + $nestingDepth + ); + } + + return $propertyConfig; + } +} \ No newline at end of file diff --git a/Service/DataSchemaService.php b/Service/DataSchemaService.php new file mode 100644 index 0000000..e926df6 --- /dev/null +++ b/Service/DataSchemaService.php @@ -0,0 +1,322 @@ + + */ +class DataSchemaService +{ + + /** + * @var Registry + */ + private $doctrine; + + /** + * @var int + */ + private $nestingDepth; + + /** + * @var DataTransformerRegistry + */ + private $dataTransformerRegistry; + + /** + * @var FileLocator + */ + private $scopeFileLocator; + + /** + * @var FileLocator + */ + private $dataSchemaFileLocator; + + /** + * @var array + */ + private $dataSchemaConfigCache = []; + + /** + * @var Stopwatch|null + */ + private $stopwatch; + + /** + * DataSchemaService constructor. + */ + public function __construct(Registry $doctrine, + DataTransformerRegistry $dataTransformerRegistry, + string $dataSchemaDir, + string $scopeDir, + int $nestingDepth, + ?Stopwatch $stopwatch) + { + $this->doctrine = $doctrine; + $this->dataTransformerRegistry = $dataTransformerRegistry; + $this->nestingDepth = $nestingDepth; + $this->dataSchemaFileLocator = new FileLocator($dataSchemaDir); + $this->scopeFileLocator = new FileLocator($scopeDir); + $this->stopwatch = $stopwatch; + } + + /** + * @param array $configuration + * @return array + * @throws InvalidConfigurationException + */ + public function processSchemaConfiguration(array $configuration): array + { + $processor = new Processor(); + + $dataSchemaConfiguration = new DataSchemaConfiguration($this->nestingDepth); + + try { + return $processor->processConfiguration( + $dataSchemaConfiguration, + [$configuration] + ); + } catch (\Exception $e) { + throw new InvalidConfigurationException($configuration, $e->getMessage()); + } + } + + /** + * @param string $dataSchemaFile + * @return array + */ + public function loadSchemaConfigurationFromFile(string $dataSchemaFile): array + { + $dataSchemaLoader = new DataSchemaYamlLoader($this->dataSchemaFileLocator); + $dataSchemaLoader->load($dataSchemaFile); + + return $dataSchemaLoader->getConfiguration(); + } + + /** + * @param string $scopeFile + * @return array + */ + public function loadScopeConfiguration(string $scopeFile): array + { + $scopeLoader = new ScopeYamlLoader($this->scopeFileLocator); + $scopeLoader->load($scopeFile); + + return $scopeLoader->getConfiguration(); + } + + /** + * @param string $decodeString + * @return array dataTransformerNames + */ + public function parseDecodeString(string $decodeString): array + { + $dataTransformerNames = explode('|', $decodeString); + + return array_map('trim', $dataTransformerNames); + } + + /** + * @param string $name + */ + public function startStopwatch(string $name): void + { + if ($this->stopwatch) { + $this->stopwatch->start($name, 'GlavwebDataSchemaBundle'); + } + } + + /** + * @param string $name + */ + public function stopStopwatch(string $name): void + { + if ($this->stopwatch) { + $this->stopwatch->stop($name); + } + } + + /** + * @param string $name + */ + public function lapStopwatch(string $name): void + { + if ($this->stopwatch) { + $this->stopwatch->lap($name); + } + } + + public function isDataSchemaFileExists(string $dataSchemaFile): bool + { + try { + $this->dataSchemaFileLocator->locate($dataSchemaFile); + } catch (FileLocatorFileNotFoundException $e) { + return false; + } + + return true; + } + + /** + * @param string $dataSchemaFile + * @return array + * @throws InvalidConfigurationException + */ + public function getConfigurationFromFile(string $dataSchemaFile): array + { + if (isset($this->dataSchemaConfigCache[$dataSchemaFile])) { + return $this->dataSchemaConfigCache[$dataSchemaFile]; + } + + $dataSchemaConfig = $this->loadSchemaConfigurationFromFile($dataSchemaFile); + + $dataSchemaConfig['schema'] = $dataSchemaFile; + + $dataSchemaConfig = $this->processSchemaConfiguration($dataSchemaConfig); + + $this->dataSchemaConfigCache[$dataSchemaFile] = $dataSchemaConfig; + + return $dataSchemaConfig; + } + + /** + * @param array $configuration + * @param string $propertyName + * @return array + * @throws InvalidConfigurationException + */ + public function getPropertySourcesStack(array $configuration, string $propertyName): array + { + $depth = 0; + $propertiesStack = []; + $selects = $configuration['query']['selects'] ?? []; + $propertyConfig = $configuration['properties'][$propertyName] ?? null; + + try { + while ($currentPropertyName = $propertyConfig['source'] ?? null) { + if (array_key_exists($currentPropertyName, $selects)) { + break; + } + + if ($currentPropertyName === $propertyName) { + throw new InvalidConfigurationPropertyException( + $propertyName, "Shouldn't refer to self in \"source\" option" + ); + } + + $propertyConfig = $configuration['properties'][$currentPropertyName] ?? null; + + if (!$propertyConfig) { + throw new InvalidConfigurationPropertyException( + $propertyName, "Invalid \"source\" option. Referred property \"$currentPropertyName\" doesn't exist in configuration." + ); + } + + $propertiesStack[] = [$currentPropertyName, $propertyConfig]; + + if (++$depth > 10) { + throw new InvalidConfigurationPropertyException( + $propertyName, "Maximum referencing depth exceeded" + ); + } + + } + } catch (InvalidConfigurationPropertyException $e) { + $propertiesStackString = 'Sources stack: ' . implode( + ' > ', + [$propertyName] + array_column($propertiesStack, 0) + ); + + throw new InvalidConfigurationException($configuration, $propertiesStackString . '. ' . $e->getMessage()); + } + + return $propertiesStack; + } + + /** + * @param array $entityConfig + * @param array|null $scopeConfig + * @return array + * @throws InvalidConfigurationException + */ + public function getDatabaseFields(array $entityConfig, array $scopeConfig = null): array + { + $properties = $entityConfig['properties']; + $entityClass = $entityConfig['class']; + $discriminatorMap = $entityConfig['discriminatorMap'] ?? null; + $databaseFields = []; + + foreach ($properties as $propertyName => $propertyData) { + if (isset($propertyData['discriminator']) && $discriminatorMap + && $discriminatorMap[$propertyData['discriminator']] !== $entityClass) { + continue; + } + if ($scopeConfig && !array_key_exists($propertyName, $scopeConfig)) { + continue; + } + + $propertySourcesStack = $this->getPropertySourcesStack($entityConfig, $propertyName); + + $isVirtualProperty = !empty($propertySourcesStack); + + if ($isVirtualProperty) { + foreach ($propertySourcesStack as [$sourcePropertyName, $sourcePropertyData]) { + $isValid = $sourcePropertyData['from_db'] ?? false; + + if ($isValid && !in_array($sourcePropertyName, $databaseFields, true)) { + $databaseFields[] = $sourcePropertyName; + } + } + } else { + $isValid = $propertyData['from_db'] ?? false; + + if ($isValid && !in_array($propertyName, $databaseFields, true)) { + $databaseFields[] = $propertyName; + } + } + } + + return $databaseFields; + } + + /** + * @param string $class + * @return ClassMetadata + */ + public function getClassMetadata(string $class): ClassMetadata + { + return $this->doctrine->getManager()->getClassMetadata($class); + } + + /** + * @throws DataTransformerNotExists + */ + public function getDataTransformer(string $name): DataTransformerInterface + { + if (!$this->dataTransformerRegistry->has($name)) { + throw new DataTransformerNotExists($name); + } + + return $this->dataTransformerRegistry->get($name); + } +} \ No newline at end of file diff --git a/Service/DataSchemaValidator.php b/Service/DataSchemaValidator.php new file mode 100644 index 0000000..a6a67f0 --- /dev/null +++ b/Service/DataSchemaValidator.php @@ -0,0 +1,278 @@ +dataSchemaService = $dataSchemaService; + } + + /** + * @param string $dataSchemaFile + * @param int $nestingDepth + * @throws InvalidConfigurationException + */ + public function validateFile(string $dataSchemaFile, int $nestingDepth = 0): void + { + $configuration = $this->dataSchemaService->getConfigurationFromFile($dataSchemaFile); + + $this->validate($configuration, $nestingDepth); + } + + /** + * @param array $config + * @param int $nestingDepth + * @param bool $isNested + * @throws InvalidConfigurationException + */ + public function validate(array $config, int $nestingDepth = 0, bool $isNested = false): void + { + if ($nestingDepth < 0) { + throw new InvalidConfigurationException($config, "Maximum nesting depth exceeded"); + } + + try { + $properties = $config['properties']; + $class = $config['class']; + $schema = $config['schema']; + + if ($isNested) { + if ($schema && !$this->dataSchemaService->isDataSchemaFileExists($schema)) { + throw new InvalidConfigurationException( + $config, "Nested property refers to nonexistent file \"$schema\"" + ); + } + + if (!(($class && $properties) || $schema)) { + throw new InvalidConfigurationException( + $config, + "Nested property should have \"class\" and \"properties\" or \"schema\" property to be defined" + ); + } + } else if (!$class || !$properties) { + throw new InvalidConfigurationException( + $config, "Should has \"class\" and \"properties\" properties to be defined and not empty" + ); + } + + try { + $classMetadata = $this->getClassMetadata($config); + } catch (\Exception $e) { + throw new InvalidConfigurationException($config, $e->getMessage()); + } + + if ($properties) { + + foreach ($properties as $propertyName => $propertyConfig) { + $source = $propertyConfig['source'] ?? null; + $decode = $propertyConfig['decode'] ?? null; + $isNestedProperty = $propertyConfig['schema'] || $propertyConfig['properties']; + $isVirtualProperty = (bool)$source; + $hasDecodingFunction = (bool)$decode; + + if ($isVirtualProperty) { + $this->validateVirtualProperty($config, $propertyName); + } else { + if ($classMetadata) { + $this->validateClassProperty( + $classMetadata, + $propertyName, + $propertyConfig, + $isNestedProperty + ); + } + + if ($isNestedProperty) { + try { + $this->validate($propertyConfig, $nestingDepth - 1, true); + } catch (InvalidConfigurationException $e) { + throw new InvalidConfigurationPropertyException($propertyName, $e->getMessage()); + } + } + } + + if ($hasDecodingFunction) { + $dataTransformerNames = $this->dataSchemaService->parseDecodeString($decode); + + foreach ($dataTransformerNames as $dataTransformerName) { + try { + $this->dataSchemaService->getDataTransformer($dataTransformerName); + } catch (DataTransformerNotExists $e) { + throw new InvalidConfigurationPropertyException($propertyName, $e->getMessage()); + } + } + } + } + + } + + } catch (InvalidConfigurationPropertyException | InvalidConfigurationException $e) { + throw new InvalidConfigurationException($config, $e->getMessage()); + } + } + + /** + * @param ClassMetadata $classMetadata + * @param string $name + * @param array $config + * @param bool $isNested + * @return void + * @throws InvalidConfigurationPropertyException + */ + private function validateClassProperty(ClassMetadata $classMetadata, + string $name, + array $config, + bool $isNested): void + { + $class = $classMetadata->getName(); + $discriminator = $config['discriminator'] ?? null; + + if (!$classMetadata->hasField($name) && !$classMetadata->hasAssociation($name)) { + $discriminatorMap = $classMetadata->discriminatorMap; + if (!$discriminatorMap) { + $properties = $this->getAvailableProperties($classMetadata); + + throw new InvalidConfigurationPropertyException( + $name, "Not found in class \"$class\". Available properties: " . json_encode($properties) + ); + } + + if ($discriminator) { + $subClass = $discriminatorMap[$discriminator] ?? null; + + if ($subClass) { + $subClassMetadata = $this->dataSchemaService->getClassMetadata($subClass); + if ($isNested && !$subClassMetadata->hasAssociation($name)) { + throw new InvalidConfigurationPropertyException( + $name, "Nested property should have association mapping" + ); + } + + if (!$subClassMetadata->hasField($name) && !$subClassMetadata->hasAssociation($name)) { + $this->findPropertyAndThrowExceptionIfFound($subClass, $name, $discriminatorMap); + + throw new InvalidConfigurationPropertyException( + $name, "Class \"$subClass\" and all its siblings doesn't have this property" + ); + } + } else { + $discriminators = array_keys($discriminatorMap); + throw new InvalidConfigurationPropertyException( + $name, "Invalid discriminator \"$discriminator\". Available discriminators: " . json_encode( + $discriminators + ) + ); + } + } else { + if ($isNested && !$classMetadata->hasAssociation($name)) { + throw new InvalidConfigurationPropertyException( + $name, "Nested property should have association mapping" + ); + } + + $this->findPropertyAndThrowExceptionIfFound($class, $name, $discriminatorMap); + + throw new InvalidConfigurationPropertyException( + $name, "Class \"$class\" and all its subclasses doesn't have this property" + ); + } + } else { +// @TODO fix in glavweb-datagrid-bundle +// if ($discriminator) { +// throw new InvalidConfigurationPropertyException( +// $name, "Shouldn't have \"discriminator\" property defined" +// ); +// } + } + + } + + /** + * @param array $config + * @param $name + * @return void + * @throws InvalidConfigurationException + */ + private function validateVirtualProperty(array $config, $name): void + { + $this->dataSchemaService->getPropertySourcesStack($config, $name); + } + + /** + * @param $class + * @param string $name + * @param array $discriminatorMap + * @throws InvalidConfigurationPropertyException + */ + private function findPropertyAndThrowExceptionIfFound($class, string $name, array $discriminatorMap): void + { + foreach ($discriminatorMap as $discriminator => $mappedClass) { + if ($class === $mappedClass) { + continue; + } + + $mappedClassMetadata = $this->dataSchemaService->getClassMetadata($mappedClass); + + if ($mappedClassMetadata->hasField($name) || $mappedClassMetadata->hasAssociation($name)) { + throw new InvalidConfigurationPropertyException( + $name, + "Class \"$class\" don't have this property, but \"$mappedClass\" has. " + . "You probably meant to use the \"$discriminator\" discriminator" + ); + } + } + } + + /** + * @param array $config + * @return ClassMetadata|null + */ + private function getClassMetadata(array $config): ?ClassMetadata + { + $class = $config['class'] ?? null; + + return $class ? $this->dataSchemaService->getClassMetadata($class) : null; + } + + /** + * @param ClassMetadata $classMetadata + * @return string[] + */ + private function getAvailableProperties(ClassMetadata $classMetadata): array + { + $allProperties = array_merge($classMetadata->getFieldNames(), $classMetadata->getAssociationNames()); + + return array_map( + static function ($name) use ($classMetadata) { + if ($classMetadata->hasAssociation($name)) { + $type = $classMetadata->getAssociationTargetClass($name); + } else { + $type = $classMetadata->getTypeOfField($name); + } + + if ($classMetadata->isCollectionValuedAssociation($name)) { + $type .= '[]'; + } + + return "$name: $type"; + }, + $allProperties + ); + } +} \ No newline at end of file diff --git a/Util/Utils.php b/Util/Utils.php new file mode 100755 index 0000000..bdfb610 --- /dev/null +++ b/Util/Utils.php @@ -0,0 +1,34 @@ + + */ +class Utils +{ + public static function arrayDeepMerge(...$arrays): array + { + $result = []; + + foreach ($arrays as $array) { + foreach ($array as $key => $value) { + if (is_int($key)) { + $result[] = $value; + + } elseif (isset($result[$key]) && is_array($result[$key]) && is_array($value)) { + $result[$key] = self::arrayDeepMerge($result[$key], $value); + + } else { + $result[$key] = $value; + } + } + } + + return $result; + } +} \ No newline at end of file diff --git a/composer.json b/composer.json old mode 100644 new mode 100755 index c5bb4bf..c527768 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "require": { "php": "^7.1.3", "symfony/config": "^2.7|^3.0|^4.0", + "symfony/finder": "^2.7|^3.0|^4.0", "symfony/dependency-injection": "^2.7|^3.0|^4.0", "symfony/yaml": "^2.7|^3.0|^4.0", "doctrine/orm": "^2.3",