From 19d3789c24606f4a96888804e2bf70a86aebd2ed Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 3 Mar 2026 16:01:15 +0200 Subject: [PATCH] transactional save --- .../Espo/Core/Repositories/Database.php | 51 +++++++++++ .../metadata/app/entityManagerParams.json | 84 +++++++++++++++++++ .../Resources/i18n/en_US/EntityManager.json | 4 +- .../metadata/app/entityManagerParams.json | 28 +++++++ schema/metadata/entityDefs.json | 4 + 5 files changed, 170 insertions(+), 1 deletion(-) diff --git a/application/Espo/Core/Repositories/Database.php b/application/Espo/Core/Repositories/Database.php index 93665ba4ea..db260236d6 100644 --- a/application/Espo/Core/Repositories/Database.php +++ b/application/Espo/Core/Repositories/Database.php @@ -69,6 +69,13 @@ class Database extends RDBRepository */ protected $hooksDisabled = false; + /** + * To save and remove in a DB transaction. + * + * @since 9.4.0 + */ + protected bool $transactionalSave = false; + /** @var ?array */ private $restoreData = null; /** @var Metadata */ @@ -97,6 +104,8 @@ class Database extends RDBRepository $this->recordIdGenerator = $recordIdGenerator; $this->hooksDisabled = $this->hooksDisabled || $metadata->get("entityDefs.$entityType.hooksDisabled"); + $this->transactionalSave = $this->transactionalSave || + $metadata->get("entityDefs.$entityType.transactionalSave"); $hookMediator = null; @@ -126,6 +135,23 @@ class Database extends RDBRepository * @param array $options */ public function save(Entity $entity, array $options = []): void + { + if ($this->transactionalSave) { + $this->entityManager->getTransactionManager()->run(function () use ($entity, $options) { + $this->saveInternal($entity, $options); + }); + + return; + } + + $this->saveInternal($entity, $options); + } + + /** + * @param TEntity $entity + * @param array $options + */ + private function saveInternal(Entity $entity, array $options = []): void { if ( $entity->isNew() && @@ -144,6 +170,31 @@ class Database extends RDBRepository parent::save($entity, $options); } + /** + * Remove a record (mark as deleted). + */ + public function remove(Entity $entity, array $options = []): void + { + if ($this->transactionalSave) { + $this->entityManager->getTransactionManager()->run(function () use ($entity, $options) { + $this->removeInternal($entity, $options); + }); + + return; + } + + $this->removeInternal($entity, $options); + } + + /** + * @param TEntity $entity + * @param array $options + */ + private function removeInternal(Entity $entity, array $options = []): void + { + parent::remove($entity, $options); + } + /** * @deprecated Do not extend. Use hooks. * diff --git a/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json b/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json index dd3f40ffa7..b7bf6abbd2 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json +++ b/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json @@ -35,6 +35,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "Contact": { @@ -73,6 +80,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "Lead": { @@ -111,6 +125,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "Opportunity": { @@ -134,6 +155,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "Document": { @@ -157,6 +185,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "Case": { @@ -180,6 +215,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "KnowledgeBaseArticle": { @@ -203,6 +245,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "Meeting": { @@ -248,6 +297,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "Call": { @@ -293,6 +349,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "Task": { @@ -334,6 +397,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "TargetList": { @@ -350,6 +420,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "@Event": { @@ -427,6 +504,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } } } diff --git a/application/Espo/Resources/i18n/en_US/EntityManager.json b/application/Espo/Resources/i18n/en_US/EntityManager.json index 15f798d036..8e7eb3fb15 100644 --- a/application/Espo/Resources/i18n/en_US/EntityManager.json +++ b/application/Espo/Resources/i18n/en_US/EntityManager.json @@ -57,7 +57,8 @@ "aclContactLink": "ACL Contact Link", "aclAccountLink": "ACL Account Link", "categories": "Categories", - "lockable": "Lockable" + "lockable": "Lockable", + "transactionalSave": "Transactional Save" }, "options": { "type": { @@ -100,6 +101,7 @@ "beforeSaveApiScript": "A script called on create and update API requests before an entity is saved. Use for custom validation and duplicate checking." }, "tooltips": { + "transactionalSave": "Record saves and removals will be wrapped into DB transactions.", "lockable": "Enables the ability to lock records.", "categories": "Enable the category tree feature. Records can be placed into categories.", "aclContactLink": "The link with Contact to use when applying access control for portal users.", diff --git a/application/Espo/Resources/metadata/app/entityManagerParams.json b/application/Espo/Resources/metadata/app/entityManagerParams.json index f22719e246..7c30ee2b16 100644 --- a/application/Espo/Resources/metadata/app/entityManagerParams.json +++ b/application/Espo/Resources/metadata/app/entityManagerParams.json @@ -76,6 +76,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "@Person": { @@ -132,6 +139,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "@Base": { @@ -195,6 +209,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } }, "@BasePlus": { @@ -258,6 +279,13 @@ "type": "bool", "tooltip": true } + }, + "transactionalSave": { + "location": "entityDefs", + "fieldDefs": { + "type": "bool", + "tooltip": true + } } } } diff --git a/schema/metadata/entityDefs.json b/schema/metadata/entityDefs.json index 67820d888d..70e55ecc60 100644 --- a/schema/metadata/entityDefs.json +++ b/schema/metadata/entityDefs.json @@ -92,6 +92,10 @@ "type": "boolean", "description": "Disable hooks. As of v8.2." }, + "transactionalSave": { + "type": "boolean", + "description": "Enables record saving and removing in a DB transaction. As of v9.4." + }, "entityClassName": { "type": "string", "description": "An entity. Should implement Espo\\ORM\\Entity. Usually should extend Espo\\Core\\ORM\\Entity. As of v8.2."