diff --git a/application/Espo/Core/FieldValidation/FieldValidationManager.php b/application/Espo/Core/FieldValidation/FieldValidationManager.php index cad0f6cb06..6a532b8d11 100644 --- a/application/Espo/Core/FieldValidation/FieldValidationManager.php +++ b/application/Espo/Core/FieldValidation/FieldValidationManager.php @@ -29,14 +29,16 @@ namespace Espo\Core\FieldValidation; -use Espo\Core\Utils\Database\Orm\Defs\EntityDefs; +use Espo\Core\Utils\Log; use Espo\ORM\Defs; use Espo\ORM\Entity; - use Espo\Core\FieldValidation\Exceptions\ValidationError; use Espo\Core\FieldValidation\Validator\Data; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\FieldUtil; +use Espo\Tools\DynamicLogic\ConditionCheckerFactory; +use Espo\Tools\DynamicLogic\Exceptions\BadCondition; +use Espo\Tools\DynamicLogic\Item as LogicItem; use LogicException; use stdClass; @@ -59,7 +61,9 @@ class FieldValidationManager private FieldUtil $fieldUtil, CheckerFactory $factory, private ValidatorFactory $validatorFactory, - private Defs $defs + private Defs $defs, + private ConditionCheckerFactory $conditionCheckerFactory, + private Log $log, ) { $this->checkerFactory = $factory; } @@ -107,30 +111,38 @@ class FieldValidationManager ): array { $dataIsSet = $data !== null; + $entityType = $entity->getEntityType(); $data ??= (object) []; $params ??= new FieldValidationParams(); - - $fieldList = array_filter( - $this->fieldUtil->getEntityTypeFieldList($entity->getEntityType()), - fn ($field) => !in_array($field, $params->getSkipFieldList()) - ); - $failureList = []; - - $entityDefs = $this->defs->getEntity($entity->getEntityType()); + $fieldList = $this->getFieldList($entityType, $params); + $entityDefs = $this->defs->getEntity($entityType); foreach ($fieldList as $field) { + $typeList = null; + if ( !$entity->isNew() && $dataIsSet && !$entityDefs->tryGetField($field)?->getParam('forceValidation') && - !$this->isFieldSetInData($entity->getEntityType(), $field, $data) + !$this->isFieldSetInData($entityType, $field, $data) ) { - continue; + if (!$this->hasRequiredLogic($entityType, $field)) { + continue; + } + + $typeList = [Type::REQUIRED]; } - $itemFailureList = $this->processField($entity, $field, $params, $data, $throw); + $itemFailureList = $this->processField( + entity: $entity, + field: $field, + params: $params, + data: $data, + throw: $throw, + typeList: $typeList, + ); $failureList = array_merge($failureList, $itemFailureList); } @@ -193,6 +205,14 @@ class FieldValidationManager $validationValue = $value ?? $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, $type); $isMandatory = in_array($type, $this->getMandatoryValidationList($entityType, $field)); + if ( + $type === Type::REQUIRED && + !$validationValue && + $this->checkDynamicLogicRequired($entity, $field) + ) { + $validationValue = true; + } + $skip = !$isMandatory && (is_null($validationValue) || $validationValue === false); if ($skip) { @@ -256,6 +276,7 @@ class FieldValidationManager } /** + * @param ?string[] $typeList * @return Failure[] * @throws ValidationError */ @@ -264,12 +285,17 @@ class FieldValidationManager string $field, FieldValidationParams $params, stdClass $data, - bool $throw + bool $throw, + ?array $typeList = null, ): array { $validationList = $this->getAllValidationList($entity->getEntityType(), $field, $params); foreach ($validationList as $type) { + if ($typeList !== null && !in_array($type, $typeList)) { + continue; + } + $result = $this->check($entity, $field, $type, $data); if ($result) { @@ -435,4 +461,44 @@ class FieldValidationManager return null; } + + private function checkDynamicLogicRequired(Entity $entity, string $field): bool + { + $entityType = $entity->getEntityType(); + + /** @var stdClass[] $group */ + $group = $this->metadata->getObjects("logicDefs.$entityType.fields.$field.required.conditionGroup"); + + if (!is_array($group)) { + return false; + } + + $checker = $this->conditionCheckerFactory->create($entity); + + try { + $item = LogicItem::fromGroupDefinition($group); + + return $checker->check($item); + } catch (BadCondition $e) { + $this->log->warning("Bad logic condition for $entityType $field.", ['exception' => $e]); + } + + return false; + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType, FieldValidationParams $params): array + { + return array_filter( + $this->fieldUtil->getEntityTypeFieldList($entityType), + fn($field) => !in_array($field, $params->getSkipFieldList()) + ); + } + + private function hasRequiredLogic(string $entityType, string $field): bool + { + return (bool) $this->metadata->get("logicDefs.$entityType.fields.$field.required"); + } } diff --git a/application/Espo/Core/FieldValidation/Type.php b/application/Espo/Core/FieldValidation/Type.php new file mode 100644 index 0000000000..3f5adb252c --- /dev/null +++ b/application/Espo/Core/FieldValidation/Type.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\FieldValidation; + +/** + * @since 9.1.0 + */ +class Type +{ + public const REQUIRED = 'required'; +} diff --git a/application/Espo/Tools/LeadCapture/CaptureService.php b/application/Espo/Tools/LeadCapture/CaptureService.php index dd9da3e0ac..a2bd7c48ec 100644 --- a/application/Espo/Tools/LeadCapture/CaptureService.php +++ b/application/Espo/Tools/LeadCapture/CaptureService.php @@ -32,7 +32,8 @@ namespace Espo\Tools\LeadCapture; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Field\Link; use Espo\Core\FieldValidation\Exceptions\ValidationError; -use Espo\Core\FieldValidation\Failure; +use Espo\Core\FieldValidation\Failure as ValidationFailure; +use Espo\Core\FieldValidation\Type as ValidationType; use Espo\Core\Name\Field; use Espo\Core\PhoneNumber\Sanitizer as PhoneNumberSanitizer; use Espo\Core\Job\JobSchedulerFactory; @@ -511,7 +512,7 @@ class CaptureService $lead->addLinkMultipleId(Field::TEAMS, $leadCapture->getTargetTeamId()); } - $validationParams = ValidationParams::create()->withTypeSkipFieldList('required', $fieldList); + $validationParams = ValidationParams::create()->withTypeSkipFieldList(ValidationType::REQUIRED, $fieldList); $this->fieldValidationManager->process($lead, $data, $validationParams); @@ -520,10 +521,10 @@ class CaptureService continue; } - $notValid = $this->fieldValidationManager->check($lead, $field, 'required', $data, true); + $notValid = $this->fieldValidationManager->check($lead, $field, ValidationType::REQUIRED, $data, true); if (!$notValid) { - $failure = new Failure(Lead::ENTITY_TYPE, $field, 'required'); + $failure = new ValidationFailure(Lead::ENTITY_TYPE, $field, ValidationType::REQUIRED); throw ValidationError::create($failure); } diff --git a/tests/integration/Espo/Record/FieldValidationTest.php b/tests/integration/Espo/Record/FieldValidationTest.php index bc4c7e05f0..6ca797f508 100644 --- a/tests/integration/Espo/Record/FieldValidationTest.php +++ b/tests/integration/Espo/Record/FieldValidationTest.php @@ -31,6 +31,7 @@ namespace tests\integration\Espo\Record; use Espo\Core\Application; use Espo\Core\Exceptions\BadRequest; +use Espo\Core\FieldValidation\Type; use Espo\Core\Record\CreateParams; use Espo\Core\Record\ServiceContainer; use Espo\Core\Record\UpdateParams; @@ -515,4 +516,63 @@ class FieldValidationTest extends BaseTestCase $this->assertTrue($thrown); } + + /** + * @noinspection PhpUnhandledExceptionInspection + */ + public function testDynamicLogicRequired(): void + { + $metadata = $this->getMetadata(); + + $metadata->set('logicDefs', Account::ENTITY_TYPE, [ + 'fields' => [ + 'description' => [ + Type::REQUIRED => [ + 'conditionGroup' => [ + [ + 'type' => 'equals', + 'attribute' => 'type', + 'value' => 'Customer', + ] + ] + ] + ] + ] + ]); + + $metadata->save(); + + $this->reCreateApplication(); + + $service = $this->getContainer()->getByClass(ServiceContainer::class)->getByClass(Account::class); + + $account = $service->create((object) [ + 'name' => 'Test', + ], CreateParams::create()); + + $isThrown = false; + + try { + $service->update($account->getId(), (object) [ + 'type' => 'Customer', + ], UpdateParams::create()); + } catch (BadRequest) { + $isThrown = true; + } + + $this->assertTrue($isThrown); + + $isThrown = false; + + try { + $service->update($account->getId(), (object) [ + 'type' => 'Customer', + 'description' => 'Test.', + ], UpdateParams::create()); + } catch (BadRequest) { + $isThrown = true; + } + + $this->assertFalse($isThrown); + } }