diff --git a/application/Espo/Core/ORM/EntityFactory.php b/application/Espo/Core/ORM/EntityFactory.php index 8be3ba4e1f..49863e4b72 100644 --- a/application/Espo/Core/ORM/EntityFactory.php +++ b/application/Espo/Core/ORM/EntityFactory.php @@ -34,6 +34,8 @@ use Espo\Core\Binding\BindingContainer; use Espo\Core\Binding\BindingContainerBuilder; use Espo\Core\Binding\BindingData; use Espo\Core\InjectableFactory; +use Espo\ORM\DataLoader\Loader; +use Espo\ORM\DataLoader\RDBLoader; use Espo\ORM\Entity; use Espo\ORM\EntityFactory as EntityFactoryInterface; use Espo\ORM\EntityManager; @@ -95,11 +97,19 @@ class EntityFactory implements EntityFactoryInterface ->build() ); + $loader = $this->injectableFactory->createWithBinding( + RDBLoader::class, + BindingContainerBuilder::create() + ->bindInstance(EntityManager::class, $this->entityManager) + ->build() + ); + $bindingContainer = $this->getBindingContainer( $className, $entityType, $defs, - $relations + $relations, + $loader, ); $entity = $this->injectableFactory->createWithBinding($className, $bindingContainer); @@ -131,6 +141,7 @@ class EntityFactory implements EntityFactoryInterface string $entityType, array $defs, Relations $relations, + Loader $loader, ): BindingContainer { if (!$this->entityManager || !$this->valueAccessorFactory) { @@ -147,7 +158,8 @@ class EntityFactory implements EntityFactoryInterface ->bindInstance(EntityManager::class, $this->entityManager) ->bindInstance(ValueAccessorFactory::class, $this->valueAccessorFactory) ->bindInstance(Helper::class, $this->helper) - ->bindInstance(Relations::class, $relations); + ->bindInstance(Relations::class, $relations) + ->bindInstance(Loader::class, $loader); return new BindingContainer($data); } diff --git a/application/Espo/Modules/Crm/Entities/Call.php b/application/Espo/Modules/Crm/Entities/Call.php index ef333658be..9a1f96641e 100644 --- a/application/Espo/Modules/Crm/Entities/Call.php +++ b/application/Espo/Modules/Crm/Entities/Call.php @@ -36,6 +36,7 @@ use Espo\Core\Field\LinkParent; use Espo\Core\Name\Field; use Espo\Core\ORM\Entity; use Espo\Entities\User; +use Espo\ORM\Entity as OrmEntity; class Call extends Entity { @@ -154,4 +155,20 @@ class Call extends Entity return $this; } + + public function setAccount(Link|Account|null $account): self + { + return $this->setRelatedLinkOrEntity('account', $account); + } + + public function getAccount(): ?Account + { + /** @var ?Account */ + return $this->relations->getOne('account'); + } + + public function getParent(): ?OrmEntity + { + return $this->relations->getOne(Field::PARENT); + } } diff --git a/application/Espo/Modules/Crm/Entities/Meeting.php b/application/Espo/Modules/Crm/Entities/Meeting.php index 77c7245b5c..aaab0f9940 100644 --- a/application/Espo/Modules/Crm/Entities/Meeting.php +++ b/application/Espo/Modules/Crm/Entities/Meeting.php @@ -36,6 +36,7 @@ use Espo\Core\Field\LinkParent; use Espo\Core\Name\Field; use Espo\Core\ORM\Entity; use Espo\Entities\User; +use Espo\ORM\Entity as OrmEntity; class Meeting extends Entity { @@ -159,4 +160,20 @@ class Meeting extends Entity return $this; } + + public function setAccount(Link|Account|null $account): self + { + return $this->setRelatedLinkOrEntity('account', $account); + } + + public function getAccount(): ?Account + { + /** @var ?Account */ + return $this->relations->getOne('account'); + } + + public function getParent(): ?OrmEntity + { + return $this->relations->getOne(Field::PARENT); + } } diff --git a/application/Espo/Modules/Crm/Entities/Opportunity.php b/application/Espo/Modules/Crm/Entities/Opportunity.php index b875b1690a..bd7019d7d4 100644 --- a/application/Espo/Modules/Crm/Entities/Opportunity.php +++ b/application/Espo/Modules/Crm/Entities/Opportunity.php @@ -107,10 +107,10 @@ class Opportunity extends Entity $this->set('probability', $probability); } - public function getAccount(): ?Link + public function getAccount(): ?Account { - /** @var ?Link */ - return $this->getValueObject('account'); + /** @var ?Account */ + return $this->relations->getOne('account'); } /** diff --git a/application/Espo/Modules/Crm/Entities/Task.php b/application/Espo/Modules/Crm/Entities/Task.php index 3ed950d40d..5e2562e29a 100644 --- a/application/Espo/Modules/Crm/Entities/Task.php +++ b/application/Espo/Modules/Crm/Entities/Task.php @@ -38,6 +38,7 @@ use Espo\Core\ORM\Entity; use Espo\Entities\Attachment; use Espo\Entities\User; use Espo\ORM\Collection; +use Espo\ORM\Entity as OrmEntity; class Task extends Entity { @@ -99,15 +100,7 @@ class Task extends Entity public function setParent(Entity|LinkParent|null $parent): self { - if ($parent instanceof LinkParent) { - $this->setValueObject(Field::PARENT, $parent); - - return $this; - } - - $this->relations->set(Field::PARENT, $parent); - - return $this; + return $this->setRelatedLinkOrEntity(Field::PARENT, $parent); } /** @@ -130,4 +123,20 @@ class Task extends Entity return $this; } + + public function setAccount(Link|Account|null $account): self + { + return $this->setRelatedLinkOrEntity('account', $account); + } + + public function getAccount(): ?Account + { + /** @var ?Account */ + return $this->relations->getOne('account'); + } + + public function getParent(): ?OrmEntity + { + return $this->relations->getOne(Field::PARENT); + } } diff --git a/application/Espo/ORM/BaseEntity.php b/application/Espo/ORM/BaseEntity.php index 60bc697fbd..250a7fbac9 100644 --- a/application/Espo/ORM/BaseEntity.php +++ b/application/Espo/ORM/BaseEntity.php @@ -29,6 +29,8 @@ namespace Espo\ORM; +use Espo\ORM\DataLoader\EmptyLoader; +use Espo\ORM\DataLoader\Loader; use Espo\ORM\Name\Attribute; use Espo\ORM\Relation\EmptyRelations; use Espo\ORM\Relation\Relations; @@ -57,6 +59,7 @@ class BaseEntity implements Entity protected ?EntityManager $entityManager; private ?ValueAccessor $valueAccessor = null; readonly protected Relations $relations; + readonly private Loader $loader; /** @var array */ private array $writtenMap = []; @@ -69,6 +72,8 @@ class BaseEntity implements Entity /** @var array */ private array $valuesContainer = []; + private bool $isPartiallyLoaded = false; + /** * An ID. */ @@ -87,6 +92,7 @@ class BaseEntity implements Entity ?EntityManager $entityManager = null, ?ValueAccessorFactory $valueAccessorFactory = null, ?Relations $relations = null, + ?Loader $loader = null, ) { $this->entityType = $entityType; $this->entityManager = $entityManager; @@ -99,6 +105,7 @@ class BaseEntity implements Entity } $this->relations = $relations ?? new EmptyRelations(); + $this->loader = $loader ?? new EmptyLoader(); } /** @@ -226,9 +233,17 @@ class BaseEntity implements Entity $method = '_get' . ucfirst($attribute); if (method_exists($this, $method)) { + if ($this->isPartiallyLoaded) { + $this->loadPartiallyLoaded(); + } + return $this->$method(); } + if ($this->checkAttributeToFullyLoad($attribute)) { + $this->loadPartiallyLoaded(); + } + if ($this->hasAttribute($attribute) && $this->hasInContainer($attribute)) { return $this->getFromContainer($attribute); } @@ -351,14 +366,18 @@ class BaseEntity implements Entity $method = '_has' . ucfirst($attribute); if (method_exists($this, $method)) { + if ($this->isPartiallyLoaded) { + $this->loadPartiallyLoaded(); + } + return (bool) $this->$method(); } - if (array_key_exists($attribute, $this->valuesContainer)) { - return true; + if ($this->checkAttributeToFullyLoad($attribute)) { + $this->loadPartiallyLoaded(); } - return false; + return $this->hasInContainer($attribute); } /** @@ -893,6 +912,10 @@ class BaseEntity implements Entity return !is_null($this->id); } + if ($this->checkAttributeFetchedToFullyLoad($attribute)) { + $this->loadPartiallyLoaded(); + } + return $this->hasInFetchedContainer($attribute); } @@ -919,6 +942,17 @@ class BaseEntity implements Entity $this->writtenMap = []; } + /** + * Set as partially loaded. For internal use. + * + * @internal + * @since 9.0.0 + */ + public function setAsPartiallyLoaded(): void + { + $this->isPartiallyLoaded = true; + } + /** * Set an entity as fetched. All current attribute values will be set as those that are fetched * from DB. @@ -1091,4 +1125,29 @@ class BaseEntity implements Entity $this->populateFromArrayItem($attribute, $value); } } + + private function checkAttributeToFullyLoad(string $attribute): bool + { + return + !$this->isNew() && + $this->isPartiallyLoaded && + $this->hasAttribute($attribute) && + !$this->hasInContainer($attribute); + } + + private function checkAttributeFetchedToFullyLoad(string $attribute): bool + { + return + !$this->isNew() && + $this->isPartiallyLoaded && + $this->hasAttribute($attribute) && + !$this->hasInFetchedContainer($attribute); + } + + private function loadPartiallyLoaded(): void + { + $this->isPartiallyLoaded = false; + + $this->loader->load($this); + } } diff --git a/application/Espo/ORM/DataLoader/EmptyLoader.php b/application/Espo/ORM/DataLoader/EmptyLoader.php new file mode 100644 index 0000000000..c9d5396eae --- /dev/null +++ b/application/Espo/ORM/DataLoader/EmptyLoader.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\ORM\DataLoader; + +use Espo\ORM\Entity; + +class EmptyLoader implements Loader +{ + public function load(Entity $entity): void + {} +} diff --git a/application/Espo/ORM/DataLoader/Loader.php b/application/Espo/ORM/DataLoader/Loader.php new file mode 100644 index 0000000000..09d13f18ce --- /dev/null +++ b/application/Espo/ORM/DataLoader/Loader.php @@ -0,0 +1,37 @@ +. + * + * 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\ORM\DataLoader; + +use Espo\ORM\Entity; + +interface Loader +{ + public function load(Entity $entity): void; +} diff --git a/application/Espo/ORM/DataLoader/RDBLoader.php b/application/Espo/ORM/DataLoader/RDBLoader.php new file mode 100644 index 0000000000..8a4bb8b1ec --- /dev/null +++ b/application/Espo/ORM/DataLoader/RDBLoader.php @@ -0,0 +1,65 @@ +. + * + * 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\ORM\DataLoader; + +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +class RDBLoader implements Loader +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function load(Entity $entity): void + { + $loaded = $this->entityManager->getEntityById($entity->getEntityType(), $entity->getId()); + + if (!$loaded) { + return; + } + + foreach ($loaded->getAttributeList() as $attribute) { + if (!$loaded->has($attribute)) { + continue; + } + + $value = $loaded->get($attribute); + + if (!$entity->hasFetched($attribute)) { + $entity->setFetched($attribute, $value); + } + + if (!$entity->has($attribute)) { + $entity->set($attribute, $value); + } + } + } +} diff --git a/application/Espo/ORM/Relation/RDBRelations.php b/application/Espo/ORM/Relation/RDBRelations.php index 37b28b445e..1e1be337a5 100644 --- a/application/Espo/ORM/Relation/RDBRelations.php +++ b/application/Espo/ORM/Relation/RDBRelations.php @@ -29,9 +29,11 @@ namespace Espo\ORM\Relation; +use Espo\ORM\BaseEntity; use Espo\ORM\Entity; use Espo\ORM\EntityCollection; use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; use Espo\ORM\Query\Part\Order; use Espo\ORM\Type\RelationType; use LogicException; @@ -221,6 +223,12 @@ class RDBRelations implements Relations return null; } + $foreignEntity = $this->getPartiallyLoadedForeignEntity($relation); + + if ($foreignEntity) { + return $foreignEntity; + } + return $this->entityManager ->getRelation($this->entity, $relation) ->findOne(); @@ -284,4 +292,45 @@ class RDBRelations implements Relations ->tryGetRelation($relation) ?->getType(); } + + private function getPartiallyLoadedForeignEntity(string $relation): ?BaseEntity + { + if (!$this->entity) { + throw new LogicException(); + } + + $relationDefs = $this->entityManager + ->getDefs() + ->getEntity($this->entity->getEntityType()) + ->getRelation($relation); + + $relationType = $relationDefs->getType(); + + $id = null; + $foreignEntityType = null; + + if ($relationType === RelationType::BELONGS_TO) { + $foreignEntityType = $relationDefs->getForeignEntityType(); + $id = $this->entity->get($relation . 'Id'); + } else if ($relationType === RelationType::BELONGS_TO_PARENT) { + $foreignEntityType = $this->entity->get($relation . 'Type'); + $id = $this->entity->get($relation . 'Id'); + } + + if (!$foreignEntityType || !$id) { + return null; + } + + $foreignEntity = $this->entityManager->getNewEntity($foreignEntityType); + + if (!$foreignEntity instanceof BaseEntity) { + return null; + } + + $foreignEntity->set(Attribute::ID, $id); + $foreignEntity->setAsFetched(); + $foreignEntity->setAsPartiallyLoaded(); + + return $foreignEntity; + } } diff --git a/tests/integration/Espo/ORM/RelationsTest.php b/tests/integration/Espo/ORM/RelationsTest.php index 8574ebca3b..4b6ca8a48f 100644 --- a/tests/integration/Espo/ORM/RelationsTest.php +++ b/tests/integration/Espo/ORM/RelationsTest.php @@ -57,7 +57,9 @@ class RelationsTest extends BaseTestCase $this->assertInstanceOf(OpportunityExtended::class, $opp); $this->assertNull($opp->getRelatedAccount()); - $account = $em->createEntity(Account::ENTITY_TYPE); + /** @var Account $account */ + $account = $em->createEntity(Account::ENTITY_TYPE, ['name' => 'Account 1']); + $opp = $em->createEntity(Opportunity::ENTITY_TYPE, [ 'accountId' => $account->getId(), ]); @@ -76,6 +78,7 @@ class RelationsTest extends BaseTestCase $this->assertInstanceOf(Account::class, $account2); $this->assertNotSame($account1, $account2); $this->assertEquals($account->getId(), $account2->getId()); + $this->assertEquals($account->getName(), $account2->getName()); $em->saveEntity($opp); @@ -130,7 +133,7 @@ class RelationsTest extends BaseTestCase $em = $this->getEntityManager(); /** @var Account $account */ - $account = $em->createEntity(Account::ENTITY_TYPE); + $account = $em->createEntity(Account::ENTITY_TYPE, ['name' => 'Account 1']); // belongsTo @@ -140,6 +143,7 @@ class RelationsTest extends BaseTestCase $em->refreshEntity($opp); $this->assertEquals($account->getId(), $opp->getAccount()->getId()); + $this->assertEquals($account->getName(), $opp->getAccount()->getName()); $opp->setAccount(null); $em->saveEntity($opp, [SaveOption::SKIP_ALL => true]); @@ -156,6 +160,8 @@ class RelationsTest extends BaseTestCase $this->assertEquals($account->getId(), $task->get('parentId')); $this->assertEquals($account->getEntityType(), $task->get('parentType')); + $this->assertEquals($account->getId(), $task->getParent()->getId()); + $this->assertEquals($account->getName(), $task->getParent()->get('name')); $task = $em->getRDBRepositoryByClass(Task::class)->getNew(); $task->setParent(null);