diff --git a/application/Espo/Controllers/EntityManager.php b/application/Espo/Controllers/EntityManager.php index b49497e3c5..e039b081f6 100644 --- a/application/Espo/Controllers/EntityManager.php +++ b/application/Espo/Controllers/EntityManager.php @@ -125,6 +125,9 @@ class EntityManager if (isset($data['countDisabled'])) { $params['countDisabled'] = $data['countDisabled']; } + if (isset($data['optimisticConcurrencyControl'])) { + $params['optimisticConcurrencyControl'] = $data['optimisticConcurrencyControl']; + } $params['kanbanViewMode'] = !empty($data['kanbanViewMode']); if (!empty($data['kanbanStatusIgnoreList'])) { diff --git a/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php b/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php new file mode 100644 index 0000000000..b6e095c466 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php @@ -0,0 +1,78 @@ +metadata = $metadata; + } + + public function process(Entity $entity): void + { + $optimisticConcurrencyControl = $this->metadata + ->get(['entityDefs', $entity->getEntityType(), 'optimisticConcurrencyControl']); + + if (!$optimisticConcurrencyControl) { + return; + } + + if ($entity->isNew()) { + $entity->set('versionNumber', 1); + + return; + } + + $entity->clear('versionNumber'); + + if (!$entity->hasFetched('versionNumber')) { + return; + } + + $versionNumber = $entity->getFetched('versionNumber'); + + if ($versionNumber === null) { + $versionNumber = 0; + } + + $versionNumber++; + + $entity->set('versionNumber', $versionNumber); + } +} diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index ba85af3d48..ceef97280b 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -433,6 +433,7 @@ class Service implements Crud, unset($data->createdById); unset($data->createdByName); unset($data->createdAt); + unset($data->versionNumber); $this->filterInput($data); @@ -450,6 +451,7 @@ class Service implements Crud, unset($data->createdById); unset($data->createdByName); unset($data->createdAt); + unset($data->versionNumber); $this->filterInput($data); @@ -470,6 +472,44 @@ class Service implements Crud, { } + protected function processConcurrencyControl(Entity $entity, StdClass $data, int $versionNumber): void + { + if ($entity->get('versionNumber') === null) { + return; + } + + if ($versionNumber === $entity->get('versionNumber')) { + return; + } + + $attributeList = array_keys(get_object_vars($data)); + + $notMatchingAttributeList = []; + + foreach ($attributeList as $attribute) { + if ($entity->get($attribute) !== $data->$attribute) { + $notMatchingAttributeList[] = $attribute; + } + } + + if (empty($notMatchingAttributeList)) { + return; + } + + $values = (object) []; + + foreach ($notMatchingAttributeList as $attribute) { + $values->$attribute = $entity->get($attribute); + } + + $responseData = (object) [ + 'values' => $values, + 'versionNumber' => $entity->get('versionNumber'), + ]; + + throw ConflictSilent::createWithBody('modified', json_encode($responseData)); + } + protected function processDuplicateCheck(Entity $entity, StdClass $data): void { $duplicateList = $this->findDuplicates($entity, $data); @@ -618,6 +658,10 @@ class Service implements Crud, throw new ForbiddenSilent("No edit access."); } + if ($params->getVersionNumber() !== null) { + $this->processConcurrencyControl($entity, $data, $params->getVersionNumber()); + } + $entity->set($data); $this->processValidation($entity, $data); diff --git a/application/Espo/Core/Record/UpdateParams.php b/application/Espo/Core/Record/UpdateParams.php index 81b3761535..efafd1b2d7 100644 --- a/application/Espo/Core/Record/UpdateParams.php +++ b/application/Espo/Core/Record/UpdateParams.php @@ -33,6 +33,8 @@ class UpdateParams { private $skipDuplicateCheck = false; + private $versionNumber = null; + public function __construct() {} public function withSkipDuplicateCheck(bool $skipDuplicateCheck = true): self @@ -44,11 +46,25 @@ class UpdateParams return $obj; } + public function withVersionNumber(?int $versionNumber): self + { + $obj = clone $this; + + $obj->versionNumber = $versionNumber; + + return $obj; + } + public function skipDuplicateCheck(): bool { return $this->skipDuplicateCheck; } + public function getVersionNumber(): ?int + { + return $this->versionNumber; + } + public static function create(): self { return new self(); diff --git a/application/Espo/Core/Record/UpdateParamsFetcher.php b/application/Espo/Core/Record/UpdateParamsFetcher.php index a8f8254d00..4fef812ca2 100644 --- a/application/Espo/Core/Record/UpdateParamsFetcher.php +++ b/application/Espo/Core/Record/UpdateParamsFetcher.php @@ -48,7 +48,14 @@ class UpdateParamsFetcher $data->forceDuplicate ?? // legacy false; + $versionNumber = $request->getHeader('X-Version-Number'); + + if ($versionNumber !== null) { + $versionNumber = intval($versionNumber); + } + return UpdateParams::create() - ->withSkipDuplicateCheck($skipDuplicateCheck); + ->withSkipDuplicateCheck($skipDuplicateCheck) + ->withVersionNumber($versionNumber); } } diff --git a/application/Espo/Core/Utils/Database/Orm/Converter.php b/application/Espo/Core/Utils/Database/Orm/Converter.php index dcb7955270..da719fcf3e 100644 --- a/application/Espo/Core/Utils/Database/Orm/Converter.php +++ b/application/Espo/Core/Utils/Database/Orm/Converter.php @@ -495,6 +495,15 @@ class Converter } } + // @todo move to separate file + if ($this->metadata->get(['entityDefs', $entityType, 'optimisticConcurrencyControl'])) { + $ormMetadata[$entityType]['fields']['versionNumber'] = [ + 'type' => Entity::INT, + 'dbType' => 'bigint', + 'notExportable' => true, + ]; + } + return $ormMetadata; } diff --git a/application/Espo/Hooks/Common/VersionNumber.php b/application/Espo/Hooks/Common/VersionNumber.php new file mode 100644 index 0000000000..6fb1700a08 --- /dev/null +++ b/application/Espo/Hooks/Common/VersionNumber.php @@ -0,0 +1,51 @@ +processor = $processor; + } + + public function beforeSave(Entity $entity): void + { + $this->processor->process($entity); + } +} diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseArticle.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseArticle.json index 9831a2491c..b8568ce392 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseArticle.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/KnowledgeBaseArticle.json @@ -113,5 +113,6 @@ "collection": { "orderBy": "order", "order": "asc" - } + }, + "optimisticConcurrencyControl": true } diff --git a/application/Espo/Resources/i18n/en_US/EntityManager.json b/application/Espo/Resources/i18n/en_US/EntityManager.json index 7a0f513c81..8ab8cf35c4 100644 --- a/application/Espo/Resources/i18n/en_US/EntityManager.json +++ b/application/Espo/Resources/i18n/en_US/EntityManager.json @@ -37,7 +37,8 @@ "countDisabled": "Disable record count", "fullTextSearch": "Full-Text Search", "parentEntityTypeList": "Parent Entity Types", - "foreignLinkEntityTypeList": "Foreign Links" + "foreignLinkEntityTypeList": "Foreign Links", + "optimisticConcurrencyControl": "Optimistic concurrency control" }, "options": { "type": { @@ -70,6 +71,7 @@ "linkConflict": "Name conflict: link or field with the same name already exists." }, "tooltips": { + "optimisticConcurrencyControl": "Prevents writing conflicts.", "statusField": "Updates of this field are logged in stream.", "textFilterFields": "Fields used by text search.", "stream": "Whether entity has a Stream.", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 20c4b4f3a9..d5f5715a35 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -261,7 +261,10 @@ "Move Over": "Move Over", "Up": "Up", "Save & Continue Editing": "Save & Continue Editing", - "Save & New": "Save & New" + "Save & New": "Save & New", + "Field": "Field", + "Resolution": "Resolution", + "Resolve Conflict": "Resolve Conflict" }, "messages": { "pleaseWait": "Please wait...", @@ -325,6 +328,7 @@ "massUnfollowZeroResult": "Nothing got unfollowed", "erasePersonalDataConfirmation": "Checked fields will be erased permanently. Are you sure?", "maintenanceMode": "The application currently is in maintenance mode. Only admin users have access.\n\nMaintenance mode can be disabled at Administration → Settings.", + "resolveSaveConflict": "The record has been modified. You need to resolve the conflict before you can save the record.", "massPrintPdfMaxCountError": "Can't print more that {maxCount} records." }, "boolFilters": { @@ -665,6 +669,11 @@ "Fax": "Fax", "Home": "Home", "Other": "Other" + }, + "saveConflictResolution": { + "current": "Current", + "actual": "Actual", + "original": "Original" } }, "sets": { diff --git a/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json b/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json index 50d768036f..f38ff02cef 100644 --- a/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json +++ b/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json @@ -87,5 +87,6 @@ "orderBy": "createdAt", "order": "desc", "textFilterFields": ["name", "subject"] - } + }, + "optimisticConcurrencyControl": true } diff --git a/application/Espo/Resources/metadata/entityDefs/Template.json b/application/Espo/Resources/metadata/entityDefs/Template.json index 5134374344..7fae2f38fe 100644 --- a/application/Espo/Resources/metadata/entityDefs/Template.json +++ b/application/Espo/Resources/metadata/entityDefs/Template.json @@ -120,5 +120,6 @@ "collection": { "orderBy": "name", "order": "asc" - } + }, + "optimisticConcurrencyControl": true } \ No newline at end of file diff --git a/application/Espo/Tools/EntityManager/EntityManager.php b/application/Espo/Tools/EntityManager/EntityManager.php index 0840d0c9b3..d572103482 100644 --- a/application/Espo/Tools/EntityManager/EntityManager.php +++ b/application/Espo/Tools/EntityManager/EntityManager.php @@ -604,7 +604,6 @@ class EntityManager $this->getMetadata()->set('entityDefs', $name, $entityDefsData); } - if (isset($data['fullTextSearch'])) { $entityDefsData = [ 'collection' => [ @@ -625,6 +624,14 @@ class EntityManager $this->getMetadata()->set('entityDefs', $name, $entityDefsData); } + if (isset($data['optimisticConcurrencyControl'])) { + $entityDefsData = [ + 'optimisticConcurrencyControl' => $data['optimisticConcurrencyControl'], + ]; + + $this->getMetadata()->set('entityDefs', $name, $entityDefsData); + } + if (array_key_exists('kanbanStatusIgnoreList', $data)) { $scopeData['kanbanStatusIgnoreList'] = $data['kanbanStatusIgnoreList']; @@ -1544,6 +1551,7 @@ class EntityManager 'collection.orderBy', 'collection.order', 'collection.textFilterFields', + 'optimisticConcurrencyControl', ]); $this->getMetadata()->save(); diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index 620c735c7b..dc651884b1 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -59,6 +59,7 @@ class FieldManager 'deleted', 'skipDuplicateCheck', 'isFollowed', + 'versionNumber', 'null', 'false', 'true', diff --git a/client/res/templates/admin/entity-manager/modals/edit-entity.tpl b/client/res/templates/admin/entity-manager/modals/edit-entity.tpl index 6c94f771e6..39d22de8c5 100644 --- a/client/res/templates/admin/entity-manager/modals/edit-entity.tpl +++ b/client/res/templates/admin/entity-manager/modals/edit-entity.tpl @@ -1,42 +1,84 @@
{{translate 'resolveSaveConflict' category='messages'}}
+| {{translate 'Field'}} | +{{translate 'Resolution'}} | +{{translate 'Value'}} | +
|---|---|---|
| + + {{translate field category='fields' scope=../entityType}} + + | ++ + | +
+
+ {{{var viewKey ../this}}}
+
+ |
+