Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl;
use Espo\Core\Select\OrmSelectBuilder;
use Espo\Core\Select\AccessControl\FilterFactory as AccessControlFilterFactory;
use Espo\Core\Select\AccessControl\FilterResolverFactory as AccessControlFilterResolverFactory;
use Espo\Core\Select\SelectManager;
use Espo\Entities\User;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use RuntimeException;
class Applier
{
public function __construct(
private string $entityType,
private User $user,
private AccessControlFilterFactory $accessControlFilterFactory,
private AccessControlFilterResolverFactory $accessControlFilterResolverFactory,
private SelectManager $selectManager
) {}
public function apply(QueryBuilder $queryBuilder): void
{
// For backward compatibility.
if (
$this->selectManager->hasInheritedAccessMethod() &&
$queryBuilder instanceof OrmSelectBuilder
) {
$this->selectManager->applyAccessToQueryBuilder($queryBuilder);
return;
}
$this->applyMandatoryFilter($queryBuilder);
$accessControlFilterResolver = $this->accessControlFilterResolverFactory
->create($this->entityType, $this->user);
$filterName = $accessControlFilterResolver->resolve();
if (!$filterName) {
return;
}
// For backward compatibility.
if (
$this->selectManager->hasInheritedAccessFilterMethod($filterName) &&
$queryBuilder instanceof OrmSelectBuilder
) {
$this->selectManager->applyAccessFilterToQueryBuilder($queryBuilder, $filterName);
return;
}
if ($this->accessControlFilterFactory->has($this->entityType, $filterName)) {
$filter = $this->accessControlFilterFactory
->create($this->entityType, $this->user, $filterName);
$filter->apply($queryBuilder);
return;
}
throw new RuntimeException("No access filter '{$filterName}' for '{$this->entityType}'.");
}
private function applyMandatoryFilter(QueryBuilder $queryBuilder): void
{
$filter = $this->accessControlFilterFactory
->create($this->entityType, $this->user, 'mandatory');
$filter->apply($queryBuilder);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl;
use Espo\Core\Acl;
class DefaultFilterResolver implements FilterResolver
{
public function __construct(private string $entityType, private Acl $acl)
{}
public function resolve(): ?string
{
if ($this->acl->checkReadNo($this->entityType)) {
return 'no';
}
if ($this->acl->checkReadOnlyOwn($this->entityType)) {
return 'onlyOwn';
}
if ($this->acl->checkReadOnlyTeam($this->entityType)) {
return 'onlyTeam';
}
if ($this->acl->checkReadAll($this->entityType)) {
return 'all';
}
return 'no';
}
}

View File

@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl;
use Espo\Core\Portal\Acl;
class DefaultPortalFilterResolver implements FilterResolver
{
public function __construct(private string $entityType, private Acl $acl)
{}
public function resolve(): ?string
{
if ($this->acl->checkReadNo($this->entityType)) {
return 'no';
}
if ($this->acl->checkReadOnlyOwn($this->entityType)) {
return 'portalOnlyOwn';
}
if ($this->acl->checkReadOnlyAccount($this->entityType)) {
return 'portalOnlyAccount';
}
if ($this->acl->checkReadOnlyContact($this->entityType)) {
return 'portalOnlyContact';
}
if ($this->acl->checkReadAll($this->entityType)) {
return 'portalAll';
}
return 'no';
}
}

View File

@@ -0,0 +1,46 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* An access filter.
*
* Bindings:
* - `$entityType`
* - `Espo\Entities\User`
* - `Espo\Core\AclManager` as of v9.1.
* - `Espo\Core\Acl`
*/
interface Filter
{
public function apply(QueryBuilder $queryBuilder): void;
}

View File

@@ -0,0 +1,134 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl;
use Espo\Core\Acl;
use Espo\Core\AclManager;
use Espo\Core\Binding\Binder;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingData;
use Espo\Core\InjectableFactory;
use Espo\Core\Portal\Acl as PortalAcl;
use Espo\Core\Portal\AclManager as PortalAclManager;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use RuntimeException;
class FilterFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private AclManager $aclManager,
private Acl $acl,
) {}
public function create(string $entityType, User $user, string $name): Filter
{
$className = $this->getClassName($entityType, $name);
if (!$className) {
throw new RuntimeException("Access control filter '$name' for '$entityType' does not exist.");
}
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user)
->bindInstance(AclManager::class, $this->aclManager)
->bindInstance(Acl::class, $this->acl);
if ($user->isPortal()) {
$binder->bindInstance(PortalAcl::class, $this->acl);
$binder->bindInstance(PortalAclManager::class, $this->aclManager);
}
$binder
->for($className)
->bindValue('$entityType', $entityType);
$binder
->for(FieldHelper::class)
->bindValue('$entityType', $entityType);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
public function has(string $entityType, string $name): bool
{
return (bool) $this->getClassName($entityType, $name);
}
/**
* @return class-string<Filter>
*/
private function getClassName(string $entityType, string $name): ?string
{
if (!$name) {
throw new RuntimeException("Empty access control filter name.");
}
/** @var ?class-string<Filter> $className */
$className = $this->metadata->get(
[
'selectDefs',
$entityType,
'accessControlFilterClassNameMap',
$name,
]
);
if ($className) {
return $className;
}
return $this->getDefaultClassName($name);
}
/**
* @return class-string<Filter>
*/
private function getDefaultClassName(string $name): ?string
{
$className = 'Espo\\Core\\Select\\AccessControl\\Filters\\' . ucfirst($name);
if (!class_exists($className)) {
return null;
}
/** @var class-string<Filter> */
return $className;
}
}

View File

@@ -0,0 +1,44 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl;
/**
* Resolves an access filter. An entity type, acl and user to be passed to the constructor.
*
* Bindings:
* - `$entityType`
* - `Espo\Entities\User`
* - `Espo\Core\AclManager` as of v9.1.
* - `Espo\Core\Acl`
*/
interface FilterResolver
{
public function resolve(): ?string;
}

View File

@@ -0,0 +1,100 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl;
use Espo\Core\AclManager;
use Espo\Core\InjectableFactory;
use Espo\Core\Acl;
use Espo\Core\Portal\Acl as PortalAcl;
use Espo\Core\Portal\AclManager as PortalAclManager;
use Espo\Core\Utils\Metadata;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\Binder;
use Espo\Core\Binding\BindingData;
use Espo\Entities\User;
class FilterResolverFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private AclManager $aclManager,
private Acl $acl,
) {}
public function create(string $entityType, User $user): FilterResolver
{
$className = !$user->isPortal() ?
$this->getClassName($entityType) :
$this->getPortalClassName($entityType);
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user)
->bindInstance(AclManager::class, $this->aclManager)
->bindInstance(Acl::class, $this->acl);
if ($user->isPortal()) {
$binder->bindInstance(PortalAcl::class, $this->acl);
$binder->bindInstance(PortalAclManager::class, $this->aclManager);
}
$binder
->for($className)
->bindValue('$entityType', $entityType);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
/**
* @return class-string<FilterResolver>
*/
private function getClassName(string $entityType): string
{
/** @var class-string<FilterResolver> */
return $this->metadata->get(['selectDefs', $entityType, 'accessControlFilterResolverClassName']) ??
DefaultFilterResolver::class;
}
/**
* @return class-string<FilterResolver>
*/
private function getPortalClassName(string $entityType): string
{
/** @var class-string<FilterResolver> */
return $this->metadata->get(['selectDefs', $entityType, 'portalAccessControlFilterResolverClassName']) ??
DefaultPortalFilterResolver::class;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\FilterResolvers;
use Espo\Core\Acl;
use Espo\Core\Select\AccessControl\FilterResolver;
class Boolean implements FilterResolver
{
private string $entityType;
private Acl $acl;
public function __construct(string $entityType, Acl $acl)
{
$this->entityType = $entityType;
$this->acl = $acl;
}
public function resolve(): ?string
{
if ($this->acl->checkScope($this->entityType)) {
return 'all';
}
return 'no';
}
}

View File

@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\FilterResolvers;
use Espo\Core\Acl;
use Espo\Core\Select\AccessControl\FilterResolver;
use Espo\Entities\User;
class BooleanOwn implements FilterResolver
{
private string $entityType;
private Acl $acl;
private User $user;
public function __construct(string $entityType, Acl $acl, User $user)
{
$this->entityType = $entityType;
$this->acl = $acl;
$this->user = $user;
}
public function resolve(): ?string
{
if (!$this->acl->checkScope($this->entityType)) {
return 'no';
}
if ($this->user->isAdmin()) {
return 'all';
}
return 'onlyOwn';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\FilterResolvers;
use Espo\Core\Select\AccessControl\FilterResolver;
class Bypass implements FilterResolver
{
public function resolve(): ?string
{
return null;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Select\AccessControl\Filter;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class All implements Filter
{
public function apply(QueryBuilder $queryBuilder): void
{}
}

View File

@@ -0,0 +1,88 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Name\Field;
use Espo\Core\Select\AccessControl\Filter;
use Espo\ORM\Defs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use LogicException;
class ForeignOnlyOwn implements Filter
{
public function __construct(
private string $entityType,
private User $user,
private Metadata $metadata,
private Defs $defs
) {}
public function apply(SelectBuilder $queryBuilder): void
{
$link = $this->metadata->get(['aclDefs', $this->entityType, 'link']);
if (!$link) {
throw new LogicException("No `link` in aclDefs for {$this->entityType}.");
}
$alias = $link . 'Access';
$queryBuilder->leftJoin($link, $alias);
$foreignEntityType = $this->defs
->getEntity($this->entityType)
->getRelation($link)
->getForeignEntityType();
$foreignEntityDefs = $this->defs->getEntity($foreignEntityType);
if ($foreignEntityDefs->hasField(Field::ASSIGNED_USER)) {
$queryBuilder->where([
"{$alias}.assignedUserId" => $this->user->getId(),
]);
return;
}
if ($foreignEntityDefs->hasField(Field::CREATED_BY)) {
$queryBuilder->where([
"{$alias}.createdById" => $this->user->getId(),
]);
return;
}
$queryBuilder->where([Attribute::ID => null]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Name\Field;
use Espo\Core\Select\AccessControl\Filter;
use Espo\Entities\Team;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\Where\OrGroup;
use Espo\ORM\Query\SelectBuilder;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
use Espo\Entities\User;
use LogicException;
/**
* @noinspection PhpUnused
*/
class ForeignOnlyTeam implements Filter
{
public function __construct(
private string $entityType,
private User $user,
private Metadata $metadata,
private Defs $defs
) {}
public function apply(SelectBuilder $queryBuilder): void
{
$link = $this->metadata->get(['aclDefs', $this->entityType, 'link']);
if (!$link) {
throw new LogicException("No `link` in aclDefs for $this->entityType.");
}
$alias = "{$link}Access";
$ownerAttribute = $this->getOwnerAttribute($link);
if (!$ownerAttribute) {
$queryBuilder->where([Attribute::ID => null]);
return;
}
$teamIdList = $this->user->getTeamIdList();
if (count($teamIdList) === 0) {
$queryBuilder
->leftJoin($link, $alias)
->where(["$alias.$ownerAttribute" => $this->user->getId()]);
return;
}
$foreignEntityType = $this->getForeignEntityType($link);
$orGroup = OrGroup::create(
Condition::equal(
Expression::column("$alias.$ownerAttribute"),
$this->user->getId()
),
Condition::in(
Expression::column("$alias.id"),
SelectBuilder::create()
->from(Team::RELATIONSHIP_ENTITY_TEAM)
->select('entityId')
->where([
'teamId' => $teamIdList,
'entityType' => $foreignEntityType,
])
->build()
)
);
$queryBuilder
->leftJoin($link, $alias)
->where($orGroup)
->where(["$alias.id!=" => null]);
}
private function getOwnerAttribute(string $link): ?string
{
$foreignEntityType = $this->getForeignEntityType($link);
$foreignEntityDefs = $this->defs->getEntity($foreignEntityType);
if ($foreignEntityDefs->hasField(Field::ASSIGNED_USER)) {
return 'assignedUserId';
}
if ($foreignEntityDefs->hasField(Field::CREATED_BY)) {
return 'createdById';
}
return null;
}
private function getForeignEntityType(string $link): string
{
return $this->defs
->getEntity($this->entityType)
->getRelation($link)
->getForeignEntityType();
}
}

View File

@@ -0,0 +1,39 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Select\AccessControl\Filter;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class Mandatory implements Filter
{
public function apply(QueryBuilder $queryBuilder): void
{}
}

View File

@@ -0,0 +1,42 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Select\AccessControl\Filter;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class No implements Filter
{
public function apply(QueryBuilder $queryBuilder): void
{
$queryBuilder->where([Attribute::ID => null]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Select\AccessControl\Filter;
use Espo\Core\Select\Helpers\RelationQueryHelper;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Entities\User;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Where\OrGroup;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class OnlyOwn implements Filter
{
public function __construct(
private User $user,
private FieldHelper $fieldHelper,
private string $entityType,
private RelationQueryHelper $relationQueryHelper,
) {}
public function apply(QueryBuilder $queryBuilder): void
{
$ownItem = $this->getOwnWhereItem();
if ($this->fieldHelper->hasCollaboratorsField()) {
$this->applyCollaborators($queryBuilder, $ownItem);
return;
}
if (!$ownItem) {
$queryBuilder->where([Attribute::ID => null]);
return;
}
$queryBuilder->where($ownItem);
}
private function applyCollaborators(QueryBuilder $queryBuilder, ?WhereItem $ownItem): void
{
$sharedItem = $this->relationQueryHelper->prepareCollaboratorsWhere($this->entityType, $this->user->getId());
$orBuilder = OrGroup::createBuilder();
if ($ownItem) {
$orBuilder->add($ownItem);
}
$orBuilder->add($sharedItem);
$queryBuilder->where($orBuilder->build());
}
private function getOwnWhereItem(): ?WhereItem
{
if ($this->fieldHelper->hasAssignedUsersField()) {
return $this->relationQueryHelper->prepareAssignedUsersWhere($this->entityType, $this->user->getId());
}
if ($this->fieldHelper->hasAssignedUserField()) {
return WhereClause::fromRaw(['assignedUserId' => $this->user->getId()]);
}
if ($this->fieldHelper->hasCreatedByField()) {
return WhereClause::fromRaw(['createdById' => $this->user->getId()]);
}
return null;
}
}

View File

@@ -0,0 +1,117 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Name\Field;
use Espo\Core\Select\AccessControl\Filter;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Entities\Team;
use Espo\Entities\User;
use Espo\ORM\Defs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* @noinspection PhpUnused
*/
class OnlyTeam implements Filter
{
public function __construct(
private User $user,
private FieldHelper $fieldHelper,
private string $entityType,
private Defs $defs
) {}
public function apply(QueryBuilder $queryBuilder): void
{
if (!$this->fieldHelper->hasTeamsField()) {
$queryBuilder->where([Attribute::ID => null]);
return;
}
$subQueryBuilder = QueryBuilder::create()
->select(Attribute::ID)
->from($this->entityType)
->leftJoin(Team::RELATIONSHIP_ENTITY_TEAM, 'entityTeam', [
'entityTeam.entityId:' => Attribute::ID,
'entityTeam.entityType' => $this->entityType,
'entityTeam.deleted' => false,
]);
// Empty list is converted to false statement by ORM.
$orGroup = ['entityTeam.teamId' => $this->user->getTeamIdList()];
if ($this->fieldHelper->hasAssignedUsersField()) {
$relationDefs = $this->defs
->getEntity($this->entityType)
->getRelation(Field::ASSIGNED_USERS);
$middleEntityType = ucfirst($relationDefs->getRelationshipName());
$key1 = $relationDefs->getMidKey();
$key2 = $relationDefs->getForeignMidKey();
$subQueryBuilder->leftJoin($middleEntityType, 'assignedUsersMiddle', [
"assignedUsersMiddle.$key1:" => Attribute::ID,
'assignedUsersMiddle.deleted' => false,
]);
$orGroup["assignedUsersMiddle.$key2"] = $this->user->getId();
} else if ($this->fieldHelper->hasAssignedUserField()) {
$orGroup['assignedUserId'] = $this->user->getId();
} else if ($this->fieldHelper->hasCreatedByField()) {
$orGroup['createdById'] = $this->user->getId();
}
if ($this->fieldHelper->hasCollaboratorsField()) {
$relationDefs = $this->defs
->getEntity($this->entityType)
->getRelation(Field::COLLABORATORS);
$middleEntityType = ucfirst($relationDefs->getRelationshipName());
$key1 = $relationDefs->getMidKey();
$key2 = $relationDefs->getForeignMidKey();
$subQueryBuilder->leftJoin($middleEntityType, 'collaboratorsMiddle', [
"collaboratorsMiddle.$key1:" => Attribute::ID,
'collaboratorsMiddle.deleted' => false,
]);
$orGroup["collaboratorsMiddle.$key2"] = $this->user->getId();
}
$subQuery = $subQueryBuilder
->where(['OR' => $orGroup])
->build();
$queryBuilder->where(['id=s' => $subQuery]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Select\AccessControl\Filter;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class PortalAll implements Filter
{
public function apply(QueryBuilder $queryBuilder): void
{}
}

View File

@@ -0,0 +1,120 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Name\Field;
use Espo\Core\Portal\Acl\OwnershipChecker\MetadataProvider;
use Espo\Core\Select\AccessControl\Filter;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Core\Select\Helpers\RelationQueryHelper;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Where\OrGroup;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class PortalOnlyAccount implements Filter
{
public function __construct(
private string $entityType,
private User $user,
private FieldHelper $fieldHelper,
private MetadataProvider $metadataProvider,
private RelationQueryHelper $relationQueryHelper,
) {}
public function apply(QueryBuilder $queryBuilder): void
{
$orBuilder = OrGroup::createBuilder();
$accountIds = $this->user->getAccounts()->getIdList();
$contactId = $this->user->getContactId();
if ($accountIds !== []) {
$or = $this->prepareAccountWhere($queryBuilder, $accountIds);
if ($or) {
$orBuilder->add($or);
}
}
if ($contactId) {
$or = $this->prepareContactWhere($queryBuilder, $contactId);
if ($or) {
$orBuilder->add($or);
}
}
if ($this->fieldHelper->hasCreatedByField()) {
$orBuilder->add(
WhereClause::fromRaw([Field::CREATED_BY . 'Id' => $this->user->getId()])
);
}
$orGroup = $orBuilder->build();
if ($orGroup->getItemCount() === 0) {
$queryBuilder->where([Attribute::ID => null]);
return;
}
$queryBuilder->where($orGroup);
}
/**
* @param string[] $ids
*/
private function prepareAccountWhere(QueryBuilder $queryBuilder, array $ids): ?WhereItem
{
$defs = $this->metadataProvider->getAccountLink($this->entityType);
if (!$defs) {
return null;
}
return $this->relationQueryHelper->prepareLinkWhere($defs, Account::ENTITY_TYPE, $ids, $queryBuilder);
}
private function prepareContactWhere(QueryBuilder $queryBuilder, string $id): ?WhereItem
{
$defs = $this->metadataProvider->getContactLink($this->entityType);
if (!$defs) {
return null;
}
return $this->relationQueryHelper->prepareLinkWhere($defs, Contact::ENTITY_TYPE, $id, $queryBuilder);
}
}

View File

@@ -0,0 +1,96 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Name\Field;
use Espo\Core\Portal\Acl\OwnershipChecker\MetadataProvider;
use Espo\Core\Select\AccessControl\Filter;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Core\Select\Helpers\RelationQueryHelper;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Contact;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Where\OrGroup;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class PortalOnlyContact implements Filter
{
public function __construct(
private string $entityType,
private User $user,
private FieldHelper $fieldHelper,
private MetadataProvider $metadataProvider,
private RelationQueryHelper $relationQueryHelper,
) {}
public function apply(QueryBuilder $queryBuilder): void
{
$orBuilder = OrGroup::createBuilder();
$contactId = $this->user->getContactId();
if ($contactId) {
$or = $this->prepareContactWhere($queryBuilder, $contactId);
if ($or) {
$orBuilder->add($or);
}
}
if ($this->fieldHelper->hasCreatedByField()) {
$orBuilder->add(
WhereClause::fromRaw([Field::CREATED_BY . 'Id' => $this->user->getId()])
);
}
$orGroup = $orBuilder->build();
if ($orGroup->getItemCount() === 0) {
$queryBuilder->where([Attribute::ID => null]);
return;
}
$queryBuilder->where($orGroup);
}
private function prepareContactWhere(QueryBuilder $queryBuilder, string $id): ?WhereItem
{
$defs = $this->metadataProvider->getContactLink($this->entityType);
if (!$defs) {
return null;
}
return $this->relationQueryHelper->prepareLinkWhere($defs, Contact::ENTITY_TYPE, $id, $queryBuilder);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\AccessControl\Filters;
use Espo\Core\Select\AccessControl\Filter;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Entities\User;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class PortalOnlyOwn implements Filter
{
public function __construct(private User $user, private FieldHelper $fieldHelper)
{}
public function apply(QueryBuilder $queryBuilder): void
{
if ($this->fieldHelper->hasCreatedByField()) {
$queryBuilder->where([
'createdById' => $this->user->getId(),
]);
return;
}
$queryBuilder->where([Attribute::ID => null]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Applier;
use Espo\ORM\Query\SelectBuilder;
use Espo\Core\Select\SearchParams;
interface AdditionalApplier
{
public function apply(SelectBuilder $queryBuilder, SearchParams $searchParams): void;
}

View File

@@ -0,0 +1,66 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Applier\AdditionalAppliers;
use Espo\Core\Name\Field;
use Espo\Core\Select\Applier\AdditionalApplier;
use Espo\Core\Select\SearchParams;
use Espo\Core\Utils\Metadata;
use Espo\Entities\StarSubscription;
use Espo\Entities\User;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\SelectBuilder;
/**
* @noinspection PhpUnused
*/
class IsStarred implements AdditionalApplier
{
public function __construct(
private string $entityType,
private User $user,
private Metadata $metadata,
) {}
public function apply(SelectBuilder $queryBuilder, SearchParams $searchParams): void
{
if (!$this->metadata->get("scopes.$this->entityType.stars")) {
return;
}
$queryBuilder
->select(Expr::isNotNull(Expr::column('starSubscription.id')), Field::IS_STARRED)
->leftJoin(StarSubscription::ENTITY_TYPE, 'starSubscription', [
'userId' => $this->user->getId(),
'entityType' => $this->entityType,
'entityId:' => 'id',
]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Applier\Appliers;
use Espo\Core\Binding\ContextualBinder;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\Core\Select\SearchParams;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\Applier\AdditionalApplier;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Entities\User;
class Additional
{
public function __construct(
private User $user,
private InjectableFactory $injectableFactory,
private string $entityType,
private Metadata $metadata,
) {}
/**
* @param class-string<AdditionalApplier>[] $classNameList
*/
public function apply(array $classNameList, QueryBuilder $queryBuilder, SearchParams $searchParams): void
{
$classNameList = array_merge($this->getMandatoryClassNameList(), $classNameList);
foreach ($classNameList as $className) {
$applier = $this->createApplier($className);
$applier->apply($queryBuilder, $searchParams);
}
}
/**
* @param class-string<AdditionalApplier> $className
*/
private function createApplier(string $className): AdditionalApplier
{
return $this->injectableFactory->createWithBinding(
$className,
BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->inContext($className, function (ContextualBinder $binder) {
$binder->bindValue('$entityType', $this->entityType);
})
->build()
);
}
/**
* @return class-string<AdditionalApplier>[]
*/
private function getMandatoryClassNameList(): array
{
/** @var class-string<AdditionalApplier>[] */
return $this->metadata->get("selectDefs.$this->entityType.additionalApplierClassNameList") ?? [];
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Applier\Appliers;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class Limit
{
public function apply(QueryBuilder $queryBuilder, ?int $offset, ?int $maxSize): void
{
$queryBuilder->limit($offset, $maxSize);
}
}

View File

@@ -0,0 +1,182 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Applier;
use Espo\Core\Acl;
use Espo\Core\AclManager;
use Espo\Core\Select\Text\Applier as TextFilterApplier;
use Espo\Core\Select\AccessControl\Applier as AccessControlFilterApplier;
use Espo\Core\Select\Where\Applier as WhereApplier;
use Espo\Core\Select\Select\Applier as SelectApplier;
use Espo\Core\Select\Primary\Applier as PrimaryFilterApplier;
use Espo\Core\Select\Order\Applier as OrderApplier;
use Espo\Core\Select\Bool\Applier as BoolFilterListApplier;
use Espo\Core\Select\Applier\Appliers\Additional as AdditionalApplier;
use Espo\Core\Select\Applier\Appliers\Limit as LimitApplier;
use Espo\Core\Binding\Binder;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingData;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\SelectManager;
use Espo\Core\Select\SelectManagerFactory;
use Espo\Core\Utils\Acl\UserAclManagerProvider;
use Espo\Entities\User;
use RuntimeException;
class Factory
{
public const SELECT = 'select';
public const WHERE = 'where';
public const ORDER = 'order';
public const LIMIT = 'limit';
public const ACCESS_CONTROL_FILTER = 'accessControlFilter';
public const TEXT_FILTER = 'textFilter';
public const PRIMARY_FILTER = 'primaryFilter';
public const BOOL_FILTER_LIST = 'boolFilterList';
public const ADDITIONAL = 'additional';
/**
* @var array<string, class-string<object>>
*/
private array $defaultClassNameMap = [
self::TEXT_FILTER => TextFilterApplier::class,
self::ACCESS_CONTROL_FILTER => AccessControlFilterApplier::class,
self::WHERE => WhereApplier::class,
self::SELECT => SelectApplier::class,
self::PRIMARY_FILTER => PrimaryFilterApplier::class,
self::ORDER => OrderApplier::class,
self::BOOL_FILTER_LIST => BoolFilterListApplier::class,
self::ADDITIONAL => AdditionalApplier::class,
self::LIMIT => LimitApplier::class,
];
public function __construct(
private InjectableFactory $injectableFactory,
private UserAclManagerProvider $userAclManagerProvider,
private SelectManagerFactory $selectManagerFactory,
) {}
private function create(string $entityType, User $user, string $type): object
{
$className = $this->getDefaultClassName($type);
// SelectManager is used for backward compatibility.
$selectManager = $this->selectManagerFactory->create($entityType, $user);
$aclManager = $this->userAclManagerProvider->get($user);
$acl = $aclManager->createUserAcl($user);
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user)
->bindInstance(AclManager::class, $aclManager)
->bindInstance(Acl::class, $acl)
->bindInstance(SelectManager::class, $selectManager);
$binder
->for($className)
->bindValue('$entityType', $entityType)
->bindValue('$selectManager', $selectManager);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
public function createWhere(string $entityType, User $user): WhereApplier
{
/** @var WhereApplier */
return $this->create($entityType, $user, self::WHERE);
}
public function createSelect(string $entityType, User $user): SelectApplier
{
/** @var SelectApplier */
return $this->create($entityType, $user, self::SELECT);
}
public function createOrder(string $entityType, User $user): OrderApplier
{
/** @var OrderApplier */
return $this->create($entityType, $user, self::ORDER);
}
public function createLimit(string $entityType, User $user): LimitApplier
{
/** @var LimitApplier */
return $this->create($entityType, $user, self::LIMIT);
}
public function createAccessControlFilter(string $entityType, User $user): AccessControlFilterApplier
{
/** @var AccessControlFilterApplier */
return $this->create($entityType, $user, self::ACCESS_CONTROL_FILTER);
}
public function createTextFilter(string $entityType, User $user): TextFilterApplier
{
/** @var TextFilterApplier */
return $this->create($entityType, $user, self::TEXT_FILTER);
}
public function createPrimaryFilter(string $entityType, User $user): PrimaryFilterApplier
{
/** @var PrimaryFilterApplier */
return $this->create($entityType, $user, self::PRIMARY_FILTER);
}
public function createBoolFilterList(string $entityType, User $user): BoolFilterListApplier
{
/** @var BoolFilterListApplier */
return $this->create($entityType, $user, self::BOOL_FILTER_LIST);
}
public function createAdditional(string $entityType, User $user): AdditionalApplier
{
/** @var AdditionalApplier */
return $this->create($entityType, $user, self::ADDITIONAL);
}
/**
* @return class-string<object>
*/
private function getDefaultClassName(string $type): string
{
if (array_key_exists($type, $this->defaultClassNameMap)) {
return $this->defaultClassNameMap[$type];
}
throw new RuntimeException("Applier `$type` does not exist.");
}
}

View File

@@ -0,0 +1,124 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Bool;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Select\OrmSelectBuilder;
use Espo\Core\Select\SelectManager;
use Espo\Core\Select\Bool\FilterFactory as BoolFilterFactory;
use Espo\ORM\Query\Select;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
use Espo\ORM\Query\Part\WhereClause;
use Espo\Entities\User;
class Applier
{
public function __construct(
private string $entityType,
private User $user,
private BoolFilterFactory $boolFilterFactory,
private SelectManager $selectManager
) {}
/**
* @param string[] $boolFilterNameList
* @throws BadRequest
*/
public function apply(QueryBuilder $queryBuilder, array $boolFilterNameList): void
{
$orGroupBuilder = new OrGroupBuilder();
$isMultiple = count($boolFilterNameList) > 1;
if ($isMultiple) {
$queryBefore = $queryBuilder->build();
}
foreach ($boolFilterNameList as $filterName) {
$this->applyBoolFilter($queryBuilder, $orGroupBuilder, $filterName);
}
if ($isMultiple) {
$this->handleMultiple($queryBefore, $queryBuilder);
}
$queryBuilder->where(
$orGroupBuilder->build()
);
}
/**
* @throws BadRequest
*/
private function applyBoolFilter(
QueryBuilder $queryBuilder,
OrGroupBuilder $orGroupBuilder,
string $filterName
): void {
if ($this->boolFilterFactory->has($this->entityType, $filterName)) {
$filter = $this->boolFilterFactory->create($this->entityType, $this->user, $filterName);
$filter->apply($queryBuilder, $orGroupBuilder);
return;
}
// For backward compatibility.
if (
$this->selectManager->hasBoolFilter($filterName) &&
$queryBuilder instanceof OrmSelectBuilder
) {
$rawWhereClause = $this->selectManager->applyBoolFilterToQueryBuilder($queryBuilder, $filterName);
$whereItem = WhereClause::fromRaw($rawWhereClause);
$orGroupBuilder->add($whereItem);
return;
}
throw new BadRequest("No bool filter '$filterName' for '$this->entityType'.");
}
private function handleMultiple(Select $queryBefore, QueryBuilder $queryBuilder): void
{
$queryAfter = $queryBuilder->build();
$joinCountBefore = count($queryBefore->getJoins());
$joinCountAfter = count($queryAfter->getJoins());
if ($joinCountBefore < $joinCountAfter) {
$queryBuilder->distinct();
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Bool;
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* Applies a bool filter. A where item should be added to OrGroupBuilder.
*/
interface Filter
{
public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void;
}

View File

@@ -0,0 +1,127 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Bool;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Core\Utils\Metadata;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\Binder;
use Espo\Core\Binding\BindingData;
use Espo\Entities\User;
use RuntimeException;
class FilterFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata
) {}
public function create(string $entityType, User $user, string $name): Filter
{
$className = $this->getClassName($entityType, $name);
if (!$className) {
throw new RuntimeException("Bool filter '$name' for '$entityType' does not exist.");
}
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user)
->for($className)
->bindValue('$entityType', $entityType);
$binder
->for(FieldHelper::class)
->bindValue('$entityType', $entityType);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
public function has(string $entityType, string $name): bool
{
return (bool) $this->getClassName($entityType, $name);
}
/**
* @return ?class-string<Filter>
*/
protected function getClassName(string $entityType, string $name): ?string
{
if (!$name) {
throw new RuntimeException("Empty bool filter name.");
}
$className = $this->metadata->get(
[
'selectDefs',
$entityType,
'boolFilterClassNameMap',
$name,
]
);
if ($className) {
/** @var ?class-string<Filter> */
return $className;
}
return $this->getDefaultClassName($name);
}
/**
* @return ?class-string<Filter>
*/
protected function getDefaultClassName(string $name): ?string
{
$className1 = $this->metadata->get(['app', 'select', 'boolFilterClassNameMap', $name]);
if ($className1) {
/** @var class-string<Filter> */
return $className1;
}
$className = 'Espo\\Core\\Select\\Bool\\Filters\\' . ucfirst($name);
if (!class_exists($className)) {
return null;
}
/** @var class-string<Filter> */
return $className;
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Bool\Filters;
use Espo\Core\Select\Bool\Filter;
use Espo\Entities\StreamSubscription;
use Espo\Entities\User;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class Followed implements Filter
{
public function __construct(private string $entityType, private User $user)
{}
public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void
{
$alias = 'subscriptionFollowedBoolFilter';
$queryBuilder->leftJoin(
StreamSubscription::ENTITY_TYPE,
$alias,
[
$alias . '.entityType' => $this->entityType,
$alias . '.entityId=:' => Attribute::ID,
$alias . '.userId' => $this->user->getId(),
]
);
$orGroupBuilder->add(
WhereClause::fromRaw([
$alias . '.id!=' => null,
])
);
}
}

View File

@@ -0,0 +1,116 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Bool\Filters;
use Espo\Core\Name\Field;
use Espo\Core\Select\Bool\Filter;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Entities\User;
use Espo\ORM\Defs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* @noinspection PhpUnused
*/
class OnlyMy implements Filter
{
public const NAME = 'onlyMy';
public function __construct(
private string $entityType,
private User $user,
private FieldHelper $fieldHelper,
private Defs $defs
) {}
public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void
{
if ($this->user->isPortal()) {
$orGroupBuilder->add(
Cond::equal(
Cond::column('createdById'),
$this->user->getId()
)
);
return;
}
if ($this->fieldHelper->hasAssignedUsersField()) {
$relationDefs = $this->defs
->getEntity($this->entityType)
->getRelation(Field::ASSIGNED_USERS);
$middleEntityType = ucfirst($relationDefs->getRelationshipName());
$key1 = $relationDefs->getMidKey();
$key2 = $relationDefs->getForeignMidKey();
$subQuery = QueryBuilder::create()
->select(Attribute::ID)
->from($this->entityType)
->leftJoin($middleEntityType, 'assignedUsersMiddle', [
"assignedUsersMiddle.$key1:" => Attribute::ID,
'assignedUsersMiddle.deleted' => false,
])
->where(["assignedUsersMiddle.$key2" => $this->user->getId()])
->build();
$orGroupBuilder->add(
Cond::in(
Cond::column(Attribute::ID),
$subQuery
)
);
return;
}
if ($this->fieldHelper->hasAssignedUserField()) {
$orGroupBuilder->add(
Cond::equal(
Cond::column('assignedUserId'),
$this->user->getId()
)
);
return;
}
$orGroupBuilder->add(
Cond::equal(
Cond::column('createdById'),
$this->user->getId()
)
);
}
}

View File

@@ -0,0 +1,87 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Bool\Filters;
use Espo\Core\Name\Field;
use Espo\Core\Select\Bool\Filter;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Entities\User;
use Espo\ORM\Defs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* @noinspection PhpUnused
*/
class Shared implements Filter
{
public const NAME = 'shared';
public function __construct(
private string $entityType,
private User $user,
private FieldHelper $fieldHelper,
private Defs $defs
) {}
public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void
{
if (!$this->fieldHelper->hasCollaboratorsField()) {
return;
}
$relationDefs = $this->defs
->getEntity($this->entityType)
->getRelation(Field::COLLABORATORS);
$middleEntityType = ucfirst($relationDefs->getRelationshipName());
$key1 = $relationDefs->getMidKey();
$key2 = $relationDefs->getForeignMidKey();
$subQuery = QueryBuilder::create()
->select(Attribute::ID)
->from($this->entityType)
->leftJoin($middleEntityType, 'collaboratorsMiddle', [
"collaboratorsMiddle.$key1:" => Attribute::ID,
'collaboratorsMiddle.deleted' => false,
])
->where(["collaboratorsMiddle.$key2" => $this->user->getId()])
->build();
$orGroupBuilder->add(
Cond::in(
Cond::column('id'),
$subQuery
)
);
}
}

View File

@@ -0,0 +1,210 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Helpers;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Team;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Defs\RelationDefs;
use Espo\ORM\EntityManager;
use Espo\ORM\Entity;
use Espo\ORM\BaseEntity;
use Espo\ORM\Type\RelationType;
/**
* @todo Rewrite using EntityDefs class. Then write unit tests.
*/
class FieldHelper
{
private ?Entity $seed = null;
private const LINK_CONTACTS = 'contacts';
private const LINK_CONTACT = 'contact';
private const LINK_ACCOUNTS = 'accounts';
private const LINK_ACCOUNT = 'account';
private const LINK_PARENT = Field::PARENT;
private const LINK_TEAMS = Field::TEAMS;
private const LINK_ASSIGNED_USERS = Field::ASSIGNED_USERS;
private const LINK_ASSIGNED_USER = Field::ASSIGNED_USER;
private const LINK_CREATED_BY = Field::CREATED_BY;
private const LINK_COLLABORATORS = Field::COLLABORATORS;
public function __construct(
private string $entityType,
private EntityManager $entityManager,
private Metadata $metadata,
) {}
private function getSeed(): Entity
{
$this->seed ??= $this->entityManager->getNewEntity($this->entityType);
return $this->seed;
}
public function hasAssignedUsersField(): bool
{
if (
$this->getSeed()->hasRelation(self::LINK_ASSIGNED_USERS) &&
$this->getSeed()->hasAttribute(self::LINK_ASSIGNED_USERS . 'Ids') &&
$this->getRelationEntityType(self::LINK_ASSIGNED_USERS) === User::ENTITY_TYPE
) {
return true;
}
return false;
}
public function hasCollaboratorsField(): bool
{
if (
$this->metadata->get("scopes.$this->entityType.collaborators") &&
$this->getSeed()->hasRelation(self::LINK_COLLABORATORS) &&
$this->getSeed()->hasAttribute(self::LINK_COLLABORATORS . 'Ids') &&
$this->getRelationEntityType(self::LINK_COLLABORATORS) === User::ENTITY_TYPE
) {
return true;
}
return false;
}
public function hasAssignedUserField(): bool
{
if (
$this->getSeed()->hasAttribute(self::LINK_ASSIGNED_USER . 'Id') &&
$this->getSeed()->hasRelation(self::LINK_ASSIGNED_USER) &&
$this->getRelationEntityType(self::LINK_ASSIGNED_USER) === User::ENTITY_TYPE
) {
return true;
}
return false;
}
public function hasCreatedByField(): bool
{
if (
$this->getSeed()->hasAttribute(self::LINK_CREATED_BY . 'Id') &&
$this->getSeed()->hasRelation(self::LINK_CREATED_BY) &&
$this->getRelationEntityType(self::LINK_CREATED_BY) === User::ENTITY_TYPE
) {
return true;
}
return false;
}
public function hasTeamsField(): bool
{
if (
$this->getSeed()->hasRelation(self::LINK_TEAMS) &&
$this->getSeed()->hasAttribute(self::LINK_TEAMS . 'Ids') &&
$this->getRelationEntityType(self::LINK_TEAMS) === Team::ENTITY_TYPE
) {
return true;
}
return false;
}
public function hasContactField(): bool
{
return
$this->getSeed()->hasAttribute(self::LINK_CONTACT . 'Id') &&
$this->getRelationEntityType(self::LINK_CONTACT) === Contact::ENTITY_TYPE;
}
public function hasContactsRelation(): bool
{
return
$this->getSeed()->hasRelation(self::LINK_CONTACTS) &&
$this->getRelationEntityType(self::LINK_CONTACTS) === Contact::ENTITY_TYPE;
}
public function hasParentField(): bool
{
return
$this->getSeed()->hasAttribute(self::LINK_PARENT . 'Id') &&
$this->getSeed()->hasRelation(self::LINK_PARENT) &&
$this->getSeed()->getRelationType(self::LINK_PARENT) === RelationType::BELONGS_TO_PARENT;
}
public function hasAccountField(): bool
{
return
$this->getSeed()->hasAttribute(self::LINK_ACCOUNT . 'Id') &&
$this->getRelationEntityType(self::LINK_ACCOUNT) === Account::ENTITY_TYPE;
}
public function hasAccountsRelation(): bool
{
return
$this->getSeed()->hasRelation(self::LINK_ACCOUNTS) &&
$this->getRelationEntityType(self::LINK_ACCOUNTS) === Account::ENTITY_TYPE;
}
public function getRelationDefs(string $name): RelationDefs
{
return $this->entityManager
->getDefs()
->getEntity($this->entityType)
->getRelation($name);
}
/**
* @noinspection PhpSameParameterValueInspection
*/
private function getRelationParam(Entity $entity, string $relation, string $param): mixed
{
if ($entity instanceof BaseEntity) {
return $entity->getRelationParam($relation, $param);
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType());
if (!$entityDefs->hasRelation($relation)) {
return null;
}
return $entityDefs->getRelation($relation)->getParam($param);
}
private function getRelationEntityType(string $relation): ?string
{
return $this->getRelationParam($this->getSeed(), $relation, RelationParam::ENTITY);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Helpers;
class RandomStringGenerator
{
public function generate(): string
{
return strval(rand(10000, 99999));
}
}

View File

@@ -0,0 +1,197 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Helpers;
use Espo\Core\Name\Field;
use Espo\Entities\User;
use Espo\ORM\Defs;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem;
use Espo\ORM\Query\SelectBuilder;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\ORM\Type\RelationType;
use LogicException;
use RuntimeException;
/**
* @since 9.0.0
*/
class RelationQueryHelper
{
public function __construct(
private Defs $defs,
) {}
public function prepareAssignedUsersWhere(string $entityType, string $userId): WhereItem
{
return $this->prepareRelatedUsersWhere(
$entityType,
$userId,
Field::ASSIGNED_USERS,
User::RELATIONSHIP_ENTITY_USER
);
}
public function prepareCollaboratorsWhere(string $entityType, string $userId): WhereItem
{
return $this->prepareRelatedUsersWhere(
$entityType,
$userId,
Field::COLLABORATORS,
User::RELATIONSHIP_ENTITY_COLLABORATOR
);
}
private function prepareRelatedUsersWhere(
string $entityType,
string $userId,
string $field,
string $relationship
): WhereItem {
$relationDefs = $this->defs
->getEntity($entityType)
->getRelation($field);
$middleEntityType = ucfirst($relationDefs->getRelationshipName());
$key1 = $relationDefs->getMidKey();
$key2 = $relationDefs->getForeignMidKey();
$joinWhere = [
"m.$key1:" => Attribute::ID,
'm.deleted' => false,
];
if ($middleEntityType === $relationship) {
$joinWhere['m.entityType'] = $entityType;
}
$subQuery = QueryBuilder::create()
->select(Attribute::ID)
->from($entityType)
->leftJoin($middleEntityType, 'm', $joinWhere)
->where(["m.$key2" => $userId])
->build();
return Condition::in(
Expression::column('id'),
$subQuery
);
}
/**
* @param string|string[] $id
*
* @since 9.1.6
*/
public function prepareLinkWhereMany(string $entityType, string $link, string|array $id): WhereItem
{
$defs = $this->defs
->getEntity($entityType)
->getRelation($link);
if (!in_array($defs->getType(), [RelationType::HAS_MANY, RelationType::MANY_MANY])) {
throw new LogicException("Only many-many and has-many allowed.");
}
$builder = SelectBuilder::create()->from($entityType);
$whereItem = $this->prepareLinkWhere($defs, $entityType, $id, $builder);
if (!$whereItem) {
throw new RuntimeException("Not supported relationship.");
}
return $whereItem;
}
/**
* @internal Signature can be changed in future.
*
* @param string|string[] $id
*/
public function prepareLinkWhere(
Defs\RelationDefs $defs,
string $entityType,
string|array $id,
QueryBuilder $queryBuilder
): ?WhereItem {
$type = $defs->getType();
$link = $defs->getName();
if (
$type === RelationType::BELONGS_TO ||
$type === RelationType::HAS_ONE
) {
if ($type === RelationType::HAS_ONE) {
$queryBuilder->leftJoin($link);
}
return WhereClause::fromRaw([$link . 'Id' => $id]);
}
if ($type === RelationType::BELONGS_TO_PARENT) {
return WhereClause::fromRaw([
'parentType' => $entityType,
'parentId' => $id,
]);
}
if ($type === RelationType::MANY_MANY) {
return Cond::in(
Expr::column(Attribute::ID),
QueryBuilder::create()
->from(ucfirst($defs->getRelationshipName()), 'm')
->select($defs->getMidKey())
->where([$defs->getForeignMidKey() => $id])
->build()
);
}
if ($type === RelationType::HAS_MANY) {
return Cond::in(
Expr::column(Attribute::ID),
QueryBuilder::create()
->from($entityType, 's')
->select($defs->getForeignKey())
->where([Attribute::ID => $id])
->build()
);
}
return null;
}
}

View File

@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Helpers;
use Espo\Core\Utils\Config\ApplicationConfig;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\Entities\Preferences;
class UserTimeZoneProvider
{
public function __construct(
private User $user,
private EntityManager $entityManager,
private ApplicationConfig $applicationConfig,
) {}
public function get(): string
{
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $this->user->getId());
if (!$preferences) {
return 'UTC';
}
if ($preferences->get('timeZone') === null || $preferences->get('timeZone') === '') {
return $this->applicationConfig->getTimeZone();
}
return $preferences->get('timeZone');
}
}

View File

@@ -0,0 +1,233 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order;
use Espo\Core\Acl;
use Espo\Core\AclManager;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\ORM\Type\FieldType;
use Espo\Entities\User;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\OrderList;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\Order\Item as OrderItem;
use Espo\Core\Select\Order\Params as OrderParams;
use Espo\Core\Select\SearchParams;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use RuntimeException;
class Applier
{
public function __construct(
private string $entityType,
private MetadataProvider $metadataProvider,
private ItemConverterFactory $itemConverterFactory,
private OrdererFactory $ordererFactory,
private AclManager $aclManager,
private User $user,
) {}
/**
* @throws Forbidden
* @throws BadRequest
*/
public function apply(QueryBuilder $queryBuilder, OrderParams $params): void
{
if ($params->forceDefault()) {
$this->applyDefaultOrder($queryBuilder, $params->getOrder());
return;
}
$orderBy = $params->getOrderBy();
if ($orderBy) {
if (
str_contains($orderBy, '.') ||
str_contains($orderBy, ':')
) {
throw new Forbidden("Complex expressions are forbidden in 'orderBy'.");
}
if ($this->metadataProvider->isFieldOrderDisabled($this->entityType, $orderBy)) {
throw new Forbidden("Order by the field '$orderBy' is disabled.");
}
if ($this->metadataProvider->getFieldType($this->entityType, $orderBy) === FieldType::PASSWORD) {
throw new Forbidden("Order by field '$orderBy' is not allowed.");
}
if (
$params->applyPermissionCheck() &&
!$this->aclManager->checkField($this->user, $this->entityType, $orderBy)
) {
throw new Forbidden("Not access to order by field '$orderBy'.");
}
}
if ($orderBy === null) {
$orderBy = $this->metadataProvider->getDefaultOrderBy($this->entityType);
}
if (!$orderBy) {
return;
}
$this->applyOrder($queryBuilder, $orderBy, $params->getOrder());
}
/**
* @param SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null $order
* @throws BadRequest
*/
private function applyDefaultOrder(QueryBuilder $queryBuilder, ?string $order): void
{
$orderBy = $this->metadataProvider->getDefaultOrderBy($this->entityType);
if (!$orderBy) {
$queryBuilder->order(Attribute::ID, $order);
return;
}
if (!$order) {
$order = $this->metadataProvider->getDefaultOrder($this->entityType);
if ($order && strtolower($order) === 'desc') {
$order = SearchParams::ORDER_DESC;
} else if ($order && strtolower($order) === 'asc') {
$order = SearchParams::ORDER_ASC;
} else if ($order !== null) {
throw new RuntimeException("Bad default order.");
}
}
/** @var SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null $order */
$this->applyOrder($queryBuilder, $orderBy, $order);
}
/**
* @param SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null $order
* @throws BadRequest
*/
private function applyOrder(QueryBuilder $queryBuilder, string $orderBy, ?string $order): void
{
if (!$orderBy) {
throw new RuntimeException("Could not apply empty order.");
}
if ($order === null) {
$order = SearchParams::ORDER_ASC;
}
$hasOrderer = $this->ordererFactory->has($this->entityType, $orderBy);
if ($hasOrderer) {
$orderer = $this->ordererFactory->create($this->entityType, $orderBy);
$orderer->apply(
$queryBuilder,
OrderItem::create($orderBy, $order)
);
if ($order !== Attribute::ID) {
$queryBuilder->order(Attribute::ID, $order);
}
return;
}
$resultOrderBy = $orderBy;
$type = $this->metadataProvider->getFieldType($this->entityType, $orderBy);
$hasItemConverter = $this->itemConverterFactory->has($this->entityType, $orderBy);
if ($hasItemConverter) {
$converter = $this->itemConverterFactory->create($this->entityType, $orderBy);
$resultOrderBy = $this->orderListToArray(
$converter->convert(
OrderItem::create($orderBy, $order)
)
);
} else if (in_array($type, [FieldType::LINK, FieldType::FILE, FieldType::IMAGE, FieldType::LINK_ONE])) {
$resultOrderBy .= 'Name';
} else if ($type === FieldType::LINK_PARENT) {
$resultOrderBy .= 'Type';
} else if (
!$this->metadataProvider->hasAttribute($this->entityType, $orderBy)
) {
throw new BadRequest("Order by non-existing field '$orderBy'.");
}
$orderByAttribute = null;
if (!is_array($resultOrderBy)) {
$orderByAttribute = $resultOrderBy;
$resultOrderBy = [
[$resultOrderBy, $order]
];
}
if (
$orderBy !== Attribute::ID &&
(
!$orderByAttribute ||
!$this->metadataProvider->isAttributeParamUniqueTrue($this->entityType, $orderByAttribute)
) &&
$this->metadataProvider->hasAttribute($this->entityType, Attribute::ID)
) {
$resultOrderBy[] = [Attribute::ID, $order];
}
$queryBuilder->order($resultOrderBy);
}
/**
* @return array<array{string, string}>
*/
private function orderListToArray(OrderList $orderList): array
{
$list = [];
foreach ($orderList as $order) {
$list[] = [
$order->getExpression()->getValue(),
$order->getDirection(),
];
}
return $list;
}
}

View File

@@ -0,0 +1,90 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order;
use Espo\Core\Select\SearchParams;
use InvalidArgumentException;
/**
* Immutable.
*/
class Item
{
private string $orderBy;
/** @var SearchParams::ORDER_ASC|SearchParams::ORDER_DESC */
private string $order;
/**
* @param SearchParams::ORDER_ASC|SearchParams::ORDER_DESC $order
*/
private function __construct(string $orderBy, string $order)
{
if (
$order !== SearchParams::ORDER_ASC &&
$order !== SearchParams::ORDER_DESC
) {
throw new InvalidArgumentException("Bad order.");
}
$this->orderBy = $orderBy;
$this->order = $order;
}
/**
* @param SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null $order
*/
public static function create(string $orderBy, ?string $order = null): self
{
if ($order === null) {
$order = SearchParams::ORDER_ASC;
}
return new self($orderBy, $order);
}
/**
* Get a field.
*/
public function getOrderBy(): string
{
return $this->orderBy;
}
/**
* Get a direction.
*
* @return SearchParams::ORDER_ASC|SearchParams::ORDER_DESC
*/
public function getOrder(): string
{
return $this->order;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order;
use Espo\ORM\Query\Part\OrderList;
/**
* Converts an order item (passed from front-end) to an ORM order list.
*/
interface ItemConverter
{
public function convert(Item $item): OrderList;
}

View File

@@ -0,0 +1,113 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order;
use Espo\Entities\User;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\Binding\ContextualBinder;
use Espo\ORM\Defs\Params\FieldParam;
use RuntimeException;
class ItemConverterFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private User $user
) {}
public function has(string $entityType, string $field): bool
{
return (bool) $this->getClassName($entityType, $field);
}
public function create(string $entityType, string $field): ItemConverter
{
$className = $this->getClassName($entityType, $field);
if (!$className) {
throw new RuntimeException("Order item converter class name is not defined.");
}
$container = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->inContext($className, function (ContextualBinder $binder) use ($entityType) {
$binder->bindValue('$entityType', $entityType);
})
->build();
return $this->injectableFactory->createWithBinding($className, $container);
}
/**
* @return ?class-string<ItemConverter>
*/
private function getClassName(string $entityType, string $field): ?string
{
/** @var ?class-string<ItemConverter> $className1 */
$className1 = $this->metadata->get([
'selectDefs', $entityType, 'orderItemConverterClassNameMap', $field
]);
if ($className1) {
return $className1;
}
$type = $this->metadata->get([
'entityDefs', $entityType, 'fields', $field, FieldParam::TYPE
]);
if (!$type) {
return null;
}
/** @var ?class-string<ItemConverter> $className2 */
$className2 = $this->metadata->get([
'app', 'select', 'orderItemConverterClassNameMap', $type
]);
if ($className2) {
return $className2;
}
$className3 = 'Espo\\Core\\Select\\Order\\ItemConverters\\' . ucfirst($type) . 'Type';
if (class_exists($className3)) {
/** @var class-string<ItemConverter> */
return $className3;
}
return null;
}
}

View File

@@ -0,0 +1,51 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order\ItemConverters;
use Espo\ORM\Query\Part\OrderList;
use Espo\ORM\Query\Part\Order;
use Espo\Core\Select\Order\Item;
use Espo\Core\Select\Order\ItemConverter;
class AddressType implements ItemConverter
{
public function convert(Item $item): OrderList
{
$orderBy = $item->getOrderBy();
$order = $item->getOrder();
return OrderList::create([
Order::fromString($orderBy . 'Country')->withDirection($order),
Order::fromString($orderBy . 'City')->withDirection($order),
Order::fromString($orderBy . 'Street')->withDirection($order),
]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order\ItemConverters;
use Espo\ORM\Query\Part\OrderList;
use Espo\ORM\Query\Part\Order;
use Espo\ORM\Query\Part\Expression;
use Espo\Core\Select\Order\Item;
use Espo\Core\Select\Order\ItemConverter;
use Espo\Core\Select\SearchParams;
use Espo\Core\Utils\Metadata;
class EnumType implements ItemConverter
{
public function __construct(
private string $entityType,
private Metadata $metadata
) {}
public function convert(Item $item): OrderList
{
$orderBy = $item->getOrderBy();
$order = $item->getOrder();
/** @var ?string[] $list */
$list = $this->metadata->get([
'entityDefs', $this->entityType, 'fields', $orderBy, 'options'
]);
if (!is_array($list) || !count($list)) {
return OrderList::create([
Order::fromString($orderBy)->withDirection($order)
]);
}
$isSorted = $this->metadata->get([
'entityDefs', $this->entityType, 'fields', $orderBy, 'isSorted'
]);
if ($isSorted) {
asort($list);
}
if ($order === SearchParams::ORDER_DESC) {
$list = array_reverse($list);
}
return OrderList::create([
Order::createByPositionInList(Expression::column($orderBy), $list),
]);
}
}

View File

@@ -0,0 +1,85 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs\Params\FieldParam;
use Espo\ORM\EntityManager;
class MetadataProvider
{
public function __construct(private Metadata $metadata, private EntityManager $entityManager)
{}
public function getFieldType(string $entityType, string $field): ?string
{
return $this->metadata->get([
'entityDefs', $entityType, 'fields', $field, FieldParam::TYPE
]) ?? null;
}
public function getDefaultOrderBy(string $entityType): ?string
{
return $this->metadata->get([
'entityDefs', $entityType, 'collection', 'orderBy'
]) ?? null;
}
public function getDefaultOrder(string $entityType): ?string
{
return $this->metadata->get([
'entityDefs', $entityType, 'collection', 'order'
]) ?? null;
}
public function isFieldOrderDisabled(string $entityType, string $field): bool
{
return $this->metadata->get("entityDefs.$entityType.fields.$field.orderDisabled") ?? false;
}
public function hasAttribute(string $entityType, string $attribute): bool
{
return $this->entityManager
->getMetadata()
->getDefs()
->getEntity($entityType)
->hasAttribute($attribute);
}
public function isAttributeParamUniqueTrue(string $entityType, string $attribute): bool
{
return (bool) $this->entityManager
->getMetadata()
->getDefs()
->getEntity($entityType)
->getAttribute($attribute)
->getParam('unique');
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order;
use Espo\ORM\Query\SelectBuilder;
/**
* A custom orderer. Should call SelectBuilder::order.
*/
interface Orderer
{
public function apply(SelectBuilder $queryBuilder, Item $item): void;
}

View File

@@ -0,0 +1,81 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\Binding\ContextualBinder;
use Espo\Entities\User;
use RuntimeException;
class OrdererFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata,
private User $user
) {}
public function has(string $entityType, string $field): bool
{
return (bool) $this->getClassName($entityType, $field);
}
public function create(string $entityType, string $field): Orderer
{
$className = $this->getClassName($entityType, $field);
if (!$className) {
throw new RuntimeException("Orderer class name is not defined.");
}
$container = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->inContext($className, function (ContextualBinder $binder) use ($entityType) {
$binder->bindValue('$entityType', $entityType);
})
->build();
return $this->injectableFactory->createWithBinding($className, $container);
}
/**
* @return ?class-string<Orderer>
*/
private function getClassName(string $entityType, string $field): ?string
{
/** @var ?class-string<Orderer> */
return $this->metadata->get([
'selectDefs', $entityType, 'ordererClassNameMap', $field
]);
}
}

View File

@@ -0,0 +1,126 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Order;
use Espo\Core\Select\SearchParams;
use InvalidArgumentException;
/**
* Order parameters.
*
* Immutable.
*/
class Params
{
private bool $forceDefault = false;
private mixed $orderBy = null;
/** @var SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null */
private $order = null;
private bool $applyPermissionCheck = false;
private function __construct() {}
/**
* @param array{
* forceDefault?: bool,
* orderBy?: ?string,
* order?: SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null,
* applyPermissionCheck?: bool,
* } $params
*/
public static function fromAssoc(array $params): self
{
$object = new self();
$object->forceDefault = $params['forceDefault'] ?? false;
$object->orderBy = $params['orderBy'] ?? null;
$object->order = $params['order'] ?? null;
$object->applyPermissionCheck = $params['applyPermissionCheck'] ?? false;
foreach ($params as $key => $value) {
if (!property_exists($object, $key)) {
throw new InvalidArgumentException("Unknown parameter '{$key}'.");
}
}
if ($object->orderBy && !is_string($object->orderBy)) {
throw new InvalidArgumentException("Bad orderBy.");
}
/** @var ?string $order */
$order = $object->order;
if (
$order &&
$order !== SearchParams::ORDER_ASC &&
$order !== SearchParams::ORDER_DESC
) {
throw new InvalidArgumentException("Bad order.");
}
return $object;
}
/**
* Force default order.
*/
public function forceDefault(): bool
{
return $this->forceDefault;
}
/**
* An order-By field.
*/
public function getOrderBy(): ?string
{
/** @var ?string */
return $this->orderBy;
}
/**
* An order direction.
*
* @return SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null
*/
public function getOrder(): ?string
{
return $this->order;
}
/**
* Apply permission check.
*/
public function applyPermissionCheck(): bool
{
return $this->applyPermissionCheck;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* Need access to raw params for backward compatibility.
* The legacy select manager operates with raw params.
*/
class OrmSelectBuilder extends QueryBuilder
{
/**
* @param array<string, mixed> $params
*/
public function setRawParams(array $params): void
{
$this->params = $params;
}
}

View File

@@ -0,0 +1,72 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Primary;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Select\SelectManager;
use Espo\Core\Select\OrmSelectBuilder;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\Entities\User;
class Applier
{
public function __construct(
private string $entityType,
private User $user,
private FilterFactory $primaryFilterFactory,
private SelectManager $selectManager
) {}
/**
* @throws BadRequest
*/
public function apply(QueryBuilder $queryBuilder, string $filterName): void
{
if ($this->primaryFilterFactory->has($this->entityType, $filterName)) {
$filter = $this->primaryFilterFactory->create($this->entityType, $this->user, $filterName);
$filter->apply($queryBuilder);
return;
}
// For backward compatibility.
if (
$this->selectManager->hasPrimaryFilter($filterName) &&
$queryBuilder instanceof OrmSelectBuilder
) {
$this->selectManager->applyPrimaryFilterToQueryBuilder($queryBuilder, $filterName);
return;
}
throw new BadRequest("No primary filter '$filterName' for '$this->entityType'.");
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Primary;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* A primary filter.
*/
interface Filter
{
public function apply(QueryBuilder $queryBuilder): void;
}

View File

@@ -0,0 +1,118 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Primary;
use Espo\Core\Binding\Binder;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingData;
use Espo\Core\InjectableFactory;
use Espo\Core\Select\Helpers\FieldHelper;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use RuntimeException;
class FilterFactory
{
public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata)
{}
public function create(string $entityType, User $user, string $name): Filter
{
$className = $this->getClassName($entityType, $name);
if (!$className) {
throw new RuntimeException("Primary filter '{$name}' for '{$entityType}' does not exist.");
}
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user)
->for($className)
->bindValue('$entityType', $entityType)
->bindValue('$name', $name);
$binder
->for(FieldHelper::class)
->bindValue('$entityType', $entityType);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
public function has(string $entityType, string $name): bool
{
return (bool) $this->getClassName($entityType, $name);
}
/**
* @return ?class-string<Filter>
*/
protected function getClassName(string $entityType, string $name): ?string
{
if (!$name) {
throw new RuntimeException("Empty primary filter name.");
}
$className = $this->metadata->get(
[
'selectDefs',
$entityType,
'primaryFilterClassNameMap',
$name,
]
);
if ($className) {
return $className;
}
return $this->getDefaultClassName($name);
}
/**
* @return ?class-string<Filter>
*/
protected function getDefaultClassName(string $name): ?string
{
$className = 'Espo\\Core\\Select\\Primary\\Filters\\' . ucfirst($name);
if (!class_exists($className)) {
return null;
}
/** @var class-string<Filter> */
return $className;
}
}

View File

@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Primary\Filters;
use Espo\Core\Select\Primary\Filter;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* A dummy filter 'all'. Can be detected in a custom AdditionalApplier to instruct that the filter needs to be
* bypassed. Use this filter for special cases from code. Users can pass this filter too. Do not rely on this filter
* when dealing with access control logic.
*
* @since 9.2.0
*/
class All implements Filter
{
public const NAME = 'all';
public function apply(QueryBuilder $queryBuilder): void
{}
}

View File

@@ -0,0 +1,57 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Primary\Filters;
use Espo\Core\Select\Primary\Filter;
use Espo\Entities\StreamSubscription;
use Espo\Entities\User;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class Followed implements Filter
{
public function __construct(private string $entityType, private User $user)
{}
public function apply(QueryBuilder $queryBuilder): void
{
$alias = 'subscriptionFollowedPrimaryFilter';
$queryBuilder->join(
StreamSubscription::ENTITY_TYPE,
$alias,
[
$alias . '.entityType' => $this->entityType,
$alias . '.entityId=:' => Attribute::ID,
$alias . '.userId' => $this->user->getId(),
]
);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Primary\Filters;
use Espo\Core\Select\Primary\Filter;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* A dummy filter 'one'. Applied only when reading a single record (from the detail view).
* Can be detected in a custom AdditionalApplier to distinguish a read request from a find request.
*/
class One implements Filter
{
public const NAME = 'one';
public function apply(QueryBuilder $queryBuilder): void
{}
}

View File

@@ -0,0 +1,60 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Primary\Filters;
use Espo\Core\Select\Primary\Filter;
use Espo\Entities\StarSubscription;
use Espo\Entities\User;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* @noinspection PhpUnused
*/
class Starred implements Filter
{
public function __construct(private string $entityType, private User $user)
{}
public function apply(QueryBuilder $queryBuilder): void
{
$alias = 'starredPrimaryFilter';
$queryBuilder->join(
StarSubscription::ENTITY_TYPE,
$alias,
[
"$alias.entityType" => $this->entityType,
"$alias.entityId=:" => Attribute::ID,
"$alias.userId" => $this->user->getId(),
]
);
}
}

View File

@@ -0,0 +1,540 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Json;
use InvalidArgumentException;
use stdClass;
/**
* Search parameters.
*
* Immutable.
*/
class SearchParams
{
/** @var array<string, mixed> */
private array $rawParams = [
'select' => null,
'boolFilterList' => [],
'orderBy' => null,
'order' => null,
'maxSize' => null,
'where' => null,
'primaryFilter' => null,
'textFilter' => null,
];
public const ORDER_ASC = 'ASC';
public const ORDER_DESC = 'DESC';
private function __construct() {}
/**
* @return array<string, mixed>
*/
public function getRaw(): array
{
return $this->rawParams;
}
/**
* Attributes to be selected.
*
* @return ?string[]
*/
public function getSelect(): ?array
{
return $this->rawParams['select'] ?? null;
}
/**
* An order-by field.
*/
public function getOrderBy(): ?string
{
return $this->rawParams['orderBy'] ?? null;
}
/**
* An order direction.
*
* @return self::ORDER_ASC|self::ORDER_DESC
*/
public function getOrder(): ?string
{
return $this->rawParams['order'] ?? null;
}
/**
* An offset.
*/
public function getOffset(): ?int
{
return $this->rawParams['offset'] ?? null;
}
/**
* A max-size.
*/
public function getMaxSize(): ?int
{
return $this->rawParams['maxSize'] ?? null;
}
/**
* A text filter.
*/
public function getTextFilter(): ?string
{
return $this->rawParams['textFilter'] ?? null;
}
/**
* A primary filter.
*/
public function getPrimaryFilter(): ?string
{
return $this->rawParams['primaryFilter'] ?? null;
}
/**
* A bool filter list.
*
* @return string[]
*/
public function getBoolFilterList(): array
{
return $this->rawParams['boolFilterList'] ?? [];
}
/**
* A where.
*/
public function getWhere(): ?WhereItem
{
$raw = $this->rawParams['where'] ?? null;
if ($raw === null) {
return null;
}
return WhereItem::fromRaw([
'type' => 'and',
'value' => $raw,
]);
}
/**
* A max text attribute length.
*/
public function getMaxTextAttributeLength(): ?int
{
return $this->rawParams['maxTextAttributeLength'] ?? null;
}
/**
* With attributes to be selected. NULL means to select all attributes.
*
* @param string[]|null $select
*/
public function withSelect(?array $select): self
{
$obj = clone $this;
$obj->rawParams['select'] = $select;
return $obj;
}
/**
* With an order-by field.
*/
public function withOrderBy(?string $orderBy): self
{
$obj = clone $this;
$obj->rawParams['orderBy'] = $orderBy;
return $obj;
}
/**
* With an order direction.
*
* @param self::ORDER_ASC|self::ORDER_DESC|null $order
*/
public function withOrder(?string $order): self
{
$obj = clone $this;
$obj->rawParams['order'] = $order;
if (
$order !== null &&
$order !== self::ORDER_ASC &&
$order !== self::ORDER_DESC
) {
throw new InvalidArgumentException("order value is bad.");
}
return $obj;
}
/**
* With an offset.
*/
public function withOffset(?int $offset): self
{
$obj = clone $this;
$obj->rawParams['offset'] = $offset;
return $obj;
}
/**
* With a mix size.
*/
public function withMaxSize(?int $maxSize): self
{
$obj = clone $this;
$obj->rawParams['maxSize'] = $maxSize;
return $obj;
}
/**
* With a text filter.
*/
public function withTextFilter(?string $filter): self
{
$obj = clone $this;
$obj->rawParams['textFilter'] = $filter;
return $obj;
}
/**
* With a primary filter.
*
* @param string|null $primaryFilter
* @return $this
*/
public function withPrimaryFilter(?string $primaryFilter): self
{
$obj = clone $this;
$obj->rawParams['primaryFilter'] = $primaryFilter;
return $obj;
}
/**
* With a bool filter list. Previously set bool filter will be unset.
*
* @param string[] $boolFilterList
*/
public function withBoolFilterList(array $boolFilterList): self
{
$obj = clone $this;
$obj->rawParams['boolFilterList'] = $boolFilterList;
return $obj;
}
public function withBoolFilterAdded(string $boolFilter): self
{
$obj = clone $this;
$obj->rawParams['boolFilterList'] ??= [];
$obj->rawParams['boolFilterList'][] = $boolFilter;
return $obj;
}
/**
* With a where. The previously set where will be unset.
*/
public function withWhere(WhereItem $where): self
{
$obj = clone $this;
if ($where->getType() === WhereItem\Type::AND) {
$obj->rawParams['where'] = $where->getValue() ?? [];
return $obj;
}
$obj->rawParams['where'] = [$where->getRaw()];
return $obj;
}
/**
* With a where added.
*/
public function withWhereAdded(WhereItem $whereItem): self
{
$obj = clone $this;
$rawWhere = $obj->rawParams['where'] ?? [];
$rawWhere[] = $whereItem->getRaw();
$obj->rawParams['where'] = $rawWhere;
return $obj;
}
/**
* With max text attribute length (long texts will be cut to avoid fetching too much data).
*/
public function withMaxTextAttributeLength(?int $value): self
{
$obj = clone $this;
$obj->rawParams['maxTextAttributeLength'] = $value;
return $obj;
}
/**
* Create an empty instance.
*/
public static function create(): self
{
return new self();
}
/**
* Create an instance from a raw.
*
* @param stdClass|array<string, mixed> $params
*/
public static function fromRaw($params): self
{
if (!is_array($params) && !$params instanceof stdClass) {
throw new InvalidArgumentException();
}
if ($params instanceof stdClass) {
$params = json_decode(Json::encode($params), true);
}
$object = new self();
$rawParams = [];
$select = $params['select'] ?? null;
$orderBy = $params['orderBy'] ?? null;
$order = $params['order'] ?? null;
$offset = $params['offset'] ?? null;
$maxSize = $params['maxSize'] ?? null;
// For bc.
if (is_string($offset) && is_numeric($offset)) {
$offset = (int) $offset;
}
// For bc.
if (is_string($maxSize) && is_numeric($maxSize)) {
$maxSize = (int) $maxSize;
}
$boolFilterList = $params['boolFilterList'] ?? [];
$primaryFilter = $params['primaryFilter'] ?? null;
$textFilter = $params['textFilter'] ?? $params['q'] ?? null;
$where = $params['where'] ?? null;
$maxTextAttributeLength = $params['maxTextAttributeLength'] ?? null;
if ($select !== null && !is_array($select)) {
throw new InvalidArgumentException("select should be array.");
}
if (is_array($select)) {
foreach ($select as $item) {
if (!is_string($item)) {
throw new InvalidArgumentException("select has non-string item.");
}
}
}
if ($orderBy !== null && !is_string($orderBy)) {
throw new InvalidArgumentException("orderBy should be string.");
}
if ($order !== null && !is_string($order)) {
throw new InvalidArgumentException("order should be string.");
}
if (!is_array($boolFilterList)) {
throw new InvalidArgumentException("boolFilterList should be array.");
}
foreach ($boolFilterList as $item) {
if (!is_string($item)) {
throw new InvalidArgumentException("boolFilterList has non-string item.");
}
}
if ($primaryFilter !== null && !is_string($primaryFilter)) {
throw new InvalidArgumentException("primaryFilter should be string.");
}
if ($textFilter !== null && !is_string($textFilter)) {
throw new InvalidArgumentException("textFilter should be string.");
}
if ($where !== null && !is_array($where)) {
throw new InvalidArgumentException("where should be array.");
}
if ($offset !== null && !is_int($offset)) {
throw new InvalidArgumentException("offset should be int.");
}
if ($maxSize !== null && !is_int($maxSize)) {
throw new InvalidArgumentException("maxSize should be int.");
}
if ($maxTextAttributeLength && !is_int($maxTextAttributeLength)) {
throw new InvalidArgumentException("maxTextAttributeLength should be int.");
}
if ($order) {
$order = strtoupper($order);
if ($order !== self::ORDER_ASC && $order !== self::ORDER_DESC) {
throw new InvalidArgumentException("order value is bad.");
}
}
$rawParams['select'] = $select;
$rawParams['orderBy'] = $orderBy;
$rawParams['order'] = $order;
$rawParams['offset'] = $offset;
$rawParams['maxSize'] = $maxSize;
$rawParams['boolFilterList'] = $boolFilterList;
$rawParams['primaryFilter'] = $primaryFilter;
$rawParams['textFilter'] = $textFilter;
$rawParams['where'] = $where;
$rawParams['maxTextAttributeLength'] = $maxTextAttributeLength;
if ($where) {
$object->adjustParams($rawParams);
}
$object->rawParams = $rawParams;
return $object;
}
/**
* Merge two SelectParams instances.
*/
public static function merge(self $searchParams1, self $searchParams2): self
{
$paramList = [
'select',
'orderBy',
'order',
'maxSize',
'primaryFilter',
'textFilter',
];
$params = $searchParams2->getRaw();
$leftParams = $searchParams1->getRaw();
foreach ($paramList as $name) {
if (!is_null($leftParams[$name])) {
$params[$name] = $leftParams[$name];
}
}
foreach ($leftParams['boolFilterList'] as $item) {
if (in_array($item, $params['boolFilterList'])) {
continue;
}
$params['boolFilterList'][] = $item;
}
$params['where'] = $params['where'] ?? [];
if (!is_null($leftParams['where'])) {
foreach ($leftParams['where'] as $item) {
$params['where'][] = $item;
}
}
if (count($params['where']) === 0) {
$params['where'] = null;
}
return self::fromRaw($params);
}
/**
* For compatibility with the legacy definition.
*
* @param array<string, mixed> $params
*/
private function adjustParams(array &$params): void
{
if (!$params['where']) {
return;
}
$where = $params['where'];
foreach ($where as $i => $item) {
$type = $item['type'] ?? null;
$value = $item['value'] ?? null;
if ($type == 'bool' && !empty($value) && is_array($value)) {
$params['boolFilterList'] = $value;
unset($where[$i]);
} else if ($type === 'textFilter') {
$params['textFilter'] = $value;
unset($where[$i]);
} else if ($type == 'primary' && $value) {
$params['primaryFilter'] = $value;
unset($where[$i]);
}
}
$params['where'] = array_values($where);
}
}

View File

@@ -0,0 +1,206 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Select;
use Espo\Core\Select\SearchParams;
use Espo\Core\Utils\FieldUtil;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
class Applier
{
/** @var string[] */
private $aclAttributeList = [
'assignedUserId',
'createdById',
];
/** @var string[] */
private $aclPortalAttributeList = [
'assignedUserId',
'createdById',
'contactId',
'accountId',
];
public function __construct(
private string $entityType,
private User $user,
private FieldUtil $fieldUtil,
private MetadataProvider $metadataProvider
) {}
public function apply(QueryBuilder $queryBuilder, SearchParams $searchParams): void
{
$attributeList = $this->getSelectAttributeList($searchParams);
if ($attributeList) {
$queryBuilder->select(
$this->prepareAttributeList($attributeList, $searchParams)
);
}
}
/**
* @param string[] $attributeList
* @return array<int, array{string, string}|string>
*/
private function prepareAttributeList(array $attributeList, SearchParams $searchParams): array
{
$limit = $searchParams->getMaxTextAttributeLength();
if ($limit === null) {
return $attributeList;
}
$resultList = [];
foreach ($attributeList as $item) {
if (
$this->metadataProvider->hasAttribute($this->entityType, $item) &&
$this->metadataProvider->getAttributeType($this->entityType, $item) === Entity::TEXT &&
!$this->metadataProvider->isAttributeNotStorable($this->entityType, $item)
) {
$resultList[] = [
"LEFT:($item, $limit)",
$item
];
continue;
}
$resultList[] = $item;
}
return $resultList;
}
/**
* @return ?string[]
*/
private function getSelectAttributeList(SearchParams $searchParams): ?array
{
$passedAttributeList = $searchParams->getSelect();
if (!$passedAttributeList) {
return null;
}
if ($passedAttributeList === ['*']) {
return ['*'];
}
$attributeList = [];
if (!in_array(Attribute::ID, $passedAttributeList)) {
$attributeList[] = Attribute::ID;
}
foreach ($this->getAclAttributeList() as $attribute) {
if (in_array($attribute, $passedAttributeList)) {
continue;
}
if (!$this->metadataProvider->hasAttribute($this->entityType, $attribute)) {
continue;
}
$attributeList[] = $attribute;
}
foreach ($passedAttributeList as $attribute) {
if (in_array($attribute, $attributeList)) {
continue;
}
if (!$this->metadataProvider->hasAttribute($this->entityType, $attribute)) {
continue;
}
$attributeList[] = $attribute;
}
$orderByField = $searchParams->getOrderBy() ?? $this->metadataProvider->getDefaultOrderBy($this->entityType);
if ($orderByField) {
$sortByAttributeList = $this->fieldUtil->getAttributeList($this->entityType, $orderByField);
foreach ($sortByAttributeList as $attribute) {
if (in_array($attribute, $attributeList)) {
continue;
}
if (!$this->metadataProvider->hasAttribute($this->entityType, $attribute)) {
continue;
}
$attributeList[] = $attribute;
}
}
$selectAttributesDependencyMap =
$this->metadataProvider->getSelectAttributesDependencyMap($this->entityType) ?? [];
foreach ($selectAttributesDependencyMap as $attribute => $dependantAttributeList) {
if (!in_array($attribute, $attributeList)) {
continue;
}
foreach ($dependantAttributeList as $dependantAttribute) {
if (in_array($dependantAttribute, $attributeList)) {
continue;
}
$attributeList[] = $dependantAttribute;
}
}
return $attributeList;
}
/**
* @return string[]
*/
private function getAclAttributeList(): array
{
if ($this->user->isPortal()) {
return
$this->metadataProvider->getAclPortalAttributeList($this->entityType) ??
$this->aclPortalAttributeList;
}
return
$this->metadataProvider->getAclAttributeList($this->entityType) ??
$this->aclAttributeList;
}
}

View File

@@ -0,0 +1,105 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Select;
use Espo\Core\Utils\Metadata;
use Espo\ORM\EntityManager;
class MetadataProvider
{
public function __construct(private Metadata $metadata, private EntityManager $entityManager)
{}
public function getDefaultOrderBy(string $entityType): ?string
{
return $this->metadata->get([
'entityDefs', $entityType, 'collection', 'orderBy'
]) ?? null;
}
/**
* @return ?array<string, string[]>
*/
public function getSelectAttributesDependencyMap(string $entityType): ?array
{
return $this->metadata->get([
'selectDefs', $entityType, 'selectAttributesDependencyMap'
]) ?? null;
}
/**
* @return ?string[]
*/
public function getAclPortalAttributeList(string $entityType): ?array
{
return $this->metadata->get([
'selectDefs', $entityType, 'aclPortalAttributeList'
]) ?? null;
}
/**
* @return ?string[]
*/
public function getAclAttributeList(string $entityType): ?array
{
return $this->metadata->get([
'selectDefs', $entityType, 'aclAttributeList'
]) ?? null;
}
public function hasAttribute(string $entityType, string $attribute): bool
{
return $this->entityManager
->getMetadata()
->getDefs()
->getEntity($entityType)
->hasAttribute($attribute);
}
public function isAttributeNotStorable(string $entityType, string $attribute): bool
{
return $this->entityManager
->getMetadata()
->getDefs()
->getEntity($entityType)
->getAttribute($attribute)
->isNotStorable();
}
public function getAttributeType(string $entityType, string $attribute): string
{
return $this->entityManager
->getMetadata()
->getDefs()
->getEntity($entityType)
->getAttribute($attribute)
->getType();
}
}

View File

@@ -0,0 +1,575 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\Applier\Factory as ApplierFactory;
use Espo\Core\Select\Where\Params as WhereParams;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Select\Order\Params as OrderParams;
use Espo\Core\Select\Text\FilterParams as TextFilterParams;
use Espo\Core\Select\Where\Applier as WhereApplier;
use Espo\Core\Select\Select\Applier as SelectApplier;
use Espo\Core\Select\Order\Applier as OrderApplier;
use Espo\Core\Select\AccessControl\Applier as AccessControlFilterApplier;
use Espo\Core\Select\Primary\Applier as PrimaryFilterApplier;
use Espo\Core\Select\Bool\Applier as BoolFilterListApplier;
use Espo\Core\Select\Text\Applier as TextFilterApplier;
use Espo\Core\Select\Applier\Appliers\Limit as LimitApplier;
use Espo\Core\Select\Applier\Appliers\Additional as AdditionalApplier;
use Espo\ORM\Query\Select as Query;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\Entities\User;
use LogicException;
/**
* Builds select queries for ORM. Applies search parameters(passed from front-end),
* ACL restrictions, filters, etc.
*/
class SelectBuilder
{
private ?string $entityType = null;
private ?OrmSelectBuilder $queryBuilder = null;
private ?Query $sourceQuery = null;
private ?SearchParams $searchParams = null;
private bool $applyAccessControlFilter = false;
private bool $applyDefaultOrder = false;
private ?string $textFilter = null;
private ?string $primaryFilter = null;
/** @var string[] */
private array $boolFilterList = [];
/** @var WhereItem[] */
private array $whereItemList = [];
private bool $applyWherePermissionCheck = false;
private bool $applyComplexExpressionsForbidden = false;
/** @var class-string<Applier\AdditionalApplier>[] */
private array $additionalApplierClassNameList = [];
public function __construct(
private User $user,
private ApplierFactory $applierFactory
) {}
/**
* Specify an entity type to select from.
*/
public function from(string $entityType): self
{
if ($this->sourceQuery) {
throw new LogicException("Can't call 'from' after 'clone'.");
}
$this->entityType = $entityType;
return $this;
}
/**
* Start building from an existing select query.
*/
public function clone(Query $query): self
{
if ($this->entityType && $this->entityType !== $query->getFrom()) {
throw new LogicException("Not matching entity type.");
}
$this->entityType = $query->getFrom();
$this->sourceQuery = $query;
return $this;
}
/**
* Build a result query.
*
* @throws Forbidden
* @throws BadRequest
*/
public function build(): Query
{
return $this->buildQueryBuilder()->build();
}
/**
* Build an ORM query builder. Used to continue building but by means of ORM.
*
* @throws Forbidden
* @throws BadRequest
*/
public function buildQueryBuilder(): QueryBuilder
{
$this->queryBuilder = new OrmSelectBuilder();
if (!$this->entityType) {
throw new LogicException("No entity type.");
}
if ($this->sourceQuery) {
$this->queryBuilder->clone($this->sourceQuery);
} else {
$this->queryBuilder->from($this->entityType);
}
$this->applyFromSearchParams();
if (count($this->whereItemList)) {
$this->applyWhereItemList();
}
if ($this->applyDefaultOrder) {
$this->applyDefaultOrder();
}
if ($this->primaryFilter) {
$this->applyPrimaryFilter();
}
if (count($this->boolFilterList)) {
$this->applyBoolFilterList();
}
if ($this->textFilter) {
$this->applyTextFilter();
}
if ($this->applyAccessControlFilter) {
$this->applyAccessControlFilter();
}
$this->applyAdditional();
return $this->queryBuilder ?? throw new LogicException();
}
/**
* Switch a user for whom a select query will be built.
*/
public function forUser(User $user): self
{
$this->user = $user;
return $this;
}
/**
* Apply search parameters.
*
* Note: If there's no order set in the search parameters then a default order will be applied.
*/
public function withSearchParams(SearchParams $searchParams): self
{
$this->searchParams = $searchParams;
$this->withBoolFilterList(
$searchParams->getBoolFilterList()
);
$primaryFilter = $searchParams->getPrimaryFilter();
if ($primaryFilter) {
$this->withPrimaryFilter($primaryFilter);
}
$textFilter = $searchParams->getTextFilter();
if ($textFilter !== null) {
$this->withTextFilter($textFilter);
}
return $this;
}
/**
* Apply maximum restrictions for a user.
*/
public function withStrictAccessControl(): self
{
$this->withAccessControlFilter();
$this->withWherePermissionCheck();
$this->withComplexExpressionsForbidden();
return $this;
}
/**
* Apply an access control filter.
*/
public function withAccessControlFilter(): self
{
$this->applyAccessControlFilter = true;
return $this;
}
/**
* Apply a default order.
*/
public function withDefaultOrder(): self
{
$this->applyDefaultOrder = true;
return $this;
}
/**
* Check permissions to where items.
*/
public function withWherePermissionCheck(): self
{
$this->applyWherePermissionCheck = true;
return $this;
}
/**
* Forbid complex expression usage.
*/
public function withComplexExpressionsForbidden(): self
{
$this->applyComplexExpressionsForbidden = true;
return $this;
}
/**
* Apply a text filter.
*/
public function withTextFilter(string $textFilter): self
{
$this->textFilter = $textFilter;
return $this;
}
/**
* Apply a primary filter.
*/
public function withPrimaryFilter(string $primaryFilter): self
{
$this->primaryFilter = $primaryFilter;
return $this;
}
/**
* Apply a bool filter.
*/
public function withBoolFilter(string $boolFilter): self
{
$this->boolFilterList[] = $boolFilter;
return $this;
}
/**
* Apply a list of bool filters.
*
* @param string[] $boolFilterList
*/
public function withBoolFilterList(array $boolFilterList): self
{
$this->boolFilterList = array_merge($this->boolFilterList, $boolFilterList);
return $this;
}
/**
* Apply a Where Item.
*/
public function withWhere(WhereItem $whereItem): self
{
$this->whereItemList[] = $whereItem;
return $this;
}
/**
* Apply a list of additional applier class names.
*
* @param class-string<Applier\AdditionalApplier>[] $additionalApplierClassNameList
*/
public function withAdditionalApplierClassNameList(array $additionalApplierClassNameList): self
{
$this->additionalApplierClassNameList = array_merge(
$this->additionalApplierClassNameList,
$additionalApplierClassNameList
);
return $this;
}
/**
* @throws BadRequest
*/
private function applyPrimaryFilter(): void
{
assert($this->queryBuilder !== null);
assert($this->primaryFilter !== null);
$this->createPrimaryFilterApplier()
->apply(
$this->queryBuilder,
$this->primaryFilter
);
}
/**
* @throws BadRequest
*/
private function applyBoolFilterList(): void
{
assert($this->queryBuilder !== null);
$this->createBoolFilterListApplier()
->apply(
$this->queryBuilder,
$this->boolFilterList
);
}
private function applyTextFilter(): void
{
assert($this->queryBuilder !== null);
assert($this->textFilter !== null);
$this->createTextFilterApplier()
->apply(
$this->queryBuilder,
$this->textFilter,
TextFilterParams::create()
);
}
private function applyAccessControlFilter(): void
{
assert($this->queryBuilder !== null);
$this->createAccessControlFilterApplier()
->apply(
$this->queryBuilder
);
}
/**
* @throws Forbidden
* @throws BadRequest
*/
private function applyDefaultOrder(): void
{
assert($this->queryBuilder !== null);
$order = $this->searchParams?->getOrder();
$params = OrderParams::fromAssoc([
'forceDefault' => true,
'order' => $order,
]);
$this->createOrderApplier()
->apply(
$this->queryBuilder,
$params
);
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function applyWhereItemList(): void
{
foreach ($this->whereItemList as $whereItem) {
$this->applyWhereItem($whereItem);
}
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function applyWhereItem(WhereItem $whereItem): void
{
assert($this->queryBuilder !== null);
$params = WhereParams::fromAssoc([
'applyPermissionCheck' => $this->applyWherePermissionCheck,
'forbidComplexExpressions' => $this->applyComplexExpressionsForbidden,
]);
$this->createWhereApplier()
->apply(
$this->queryBuilder,
$whereItem,
$params
);
}
/**
* @throws Forbidden
* @throws BadRequest
*/
private function applyFromSearchParams(): void
{
if (!$this->searchParams) {
return;
}
assert($this->queryBuilder !== null);
if (
!$this->applyDefaultOrder &&
($this->searchParams->getOrderBy() || $this->searchParams->getOrder())
) {
$params = OrderParams::fromAssoc([
'orderBy' => $this->searchParams->getOrderBy(),
'order' => $this->searchParams->getOrder(),
'applyPermissionCheck' => $this->applyWherePermissionCheck,
]);
$this->createOrderApplier()
->apply(
$this->queryBuilder,
$params
);
}
if (!$this->searchParams->getOrderBy() && !$this->searchParams->getOrder()) {
$this->withDefaultOrder();
}
if ($this->searchParams->getMaxSize() !== null || $this->searchParams->getOffset() !== null) {
$this->createLimitApplier()
->apply(
$this->queryBuilder,
$this->searchParams->getOffset(),
$this->searchParams->getMaxSize()
);
}
if ($this->searchParams->getSelect()) {
$this->createSelectApplier()
->apply(
$this->queryBuilder,
$this->searchParams
);
}
if ($this->searchParams->getWhere()) {
$this->whereItemList[] = $this->searchParams->getWhere();
}
}
private function applyAdditional(): void
{
assert($this->queryBuilder !== null);
$searchParams = SearchParams::create()
->withBoolFilterList($this->boolFilterList)
->withPrimaryFilter($this->primaryFilter)
->withTextFilter($this->textFilter);
if ($this->searchParams) {
$searchParams = SearchParams::merge($searchParams, $this->searchParams);
}
$this->createAdditionalApplier()->apply(
$this->additionalApplierClassNameList,
$this->queryBuilder,
$searchParams
);
}
private function createWhereApplier(): WhereApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createWhere($this->entityType, $this->user);
}
private function createSelectApplier(): SelectApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createSelect($this->entityType, $this->user);
}
private function createOrderApplier(): OrderApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createOrder($this->entityType, $this->user);
}
private function createLimitApplier(): LimitApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createLimit($this->entityType, $this->user);
}
private function createAccessControlFilterApplier(): AccessControlFilterApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createAccessControlFilter($this->entityType, $this->user);
}
private function createTextFilterApplier(): TextFilterApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createTextFilter($this->entityType, $this->user);
}
private function createPrimaryFilterApplier(): PrimaryFilterApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createPrimaryFilter($this->entityType, $this->user);
}
private function createBoolFilterListApplier(): BoolFilterListApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createBoolFilterList($this->entityType, $this->user);
}
private function createAdditionalApplier(): AdditionalApplier
{
assert($this->entityType !== null);
return $this->applierFactory->createAdditional($this->entityType, $this->user);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Entities\User;
/**
* Creates instances of Select Builder.
*/
class SelectBuilderFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private User $user,
) {}
public function create(): SelectBuilder
{
return $this->injectableFactory->createWithBinding(
SelectBuilder::class,
BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->build()
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select;
use Espo\Core\Utils\Acl\UserAclManagerProvider;
use Espo\Core\Acl;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\ClassFinder;
use Espo\Entities\User;
/**
* @deprecated Use SelectBuilder instead.
*
* Creates select managers for specific entity types. You can specify a user whose ACL will be applied to queries.
* If user is not specified, then the current one will be used.
*/
class SelectManagerFactory
{
/**
* @var class-string
*/
protected string $defaultClassName = SelectManager::class;
private $user;
private $acl;
private $aclManagerProvider;
private $injectableFactory;
private $classFinder;
public function __construct(
User $user,
Acl $acl,
UserAclManagerProvider $aclManagerProvider,
InjectableFactory $injectableFactory,
ClassFinder $classFinder
) {
$this->user = $user;
$this->acl = $acl;
$this->aclManagerProvider = $aclManagerProvider;
$this->injectableFactory = $injectableFactory;
$this->classFinder = $classFinder;
}
public function create(string $entityType, ?User $user = null): SelectManager
{
$className = $this->classFinder->find('SelectManagers', $entityType);
if (!$className || !class_exists($className)) {
$className = $this->defaultClassName;
}
/** @var class-string<SelectManager> $className */
if ($user) {
$acl = $this->aclManagerProvider->get($user)->createUserAcl($user);
} else {
$acl = $this->acl;
$user = $this->user;
}
$selectManager = $this->injectableFactory->createWith($className, [
'user' => $user,
'acl' => $acl,
]);
$selectManager->setEntityType($entityType);
return $selectManager;
}
}

View File

@@ -0,0 +1,212 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text;
use Espo\Core\Select\Text\FullTextSearch\Data as FullTextSearchData;
use Espo\Core\Select\Text\FullTextSearch\DataComposerFactory as FullTextSearchDataComposerFactory;
use Espo\Core\Select\Text\FullTextSearch\DataComposer\Params as FullTextSearchDataComposerParams;
use Espo\Core\Select\Text\Filter\Data as FilterData;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\ORM\Query\Part\Order as OrderExpr;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Part\WhereItem;
use Espo\Entities\User;
class Applier
{
/** @todo Move to metadata. */
private ?int $fullTextRelevanceThreshold = null; /** @phpstan-ignore-line */
/** @todo Move to metadata. */
private int $fullTextOrderRelevanceDivider = 5; /** @phpstan-ignore-line */
private const DEFAULT_FT_ORDER = self::FT_ORDER_COMBINED;
private const DEFAULT_ATTRIBUTE_LIST = ['name'];
private const FT_ORDER_COMBINED = 0;
private const FT_ORDER_RELEVANCE = 1;
private const FT_ORDER_ORIGINAL = 3;
public function __construct(
private string $entityType,
private User $user,
private MetadataProvider $metadataProvider,
private FullTextSearchDataComposerFactory $fullTextSearchDataComposerFactory,
private FilterFactory $filterFactory,
private ConfigProvider $config
) {}
/** @noinspection PhpUnusedParameterInspection */
public function apply(QueryBuilder $queryBuilder, string $filter, FilterParams $params): void
{
$forceFullText = false;
$skipFullText = false;
if (mb_strpos($filter, 'ft:') === 0) {
$filter = mb_substr($filter, 3);
$forceFullText = true;
}
$fullTextData = $this->composeFullTextSearchData($filter);
if ($fullTextData && !$forceFullText && $this->toSkipFullText($filter)) {
$skipFullText = true;
}
$fullTextWhere = $fullTextData && !$skipFullText ?
$this->processFullTextSearch($queryBuilder, $fullTextData) : null;
$fieldList = $this->getFieldList($forceFullText, $fullTextData);
$filterData = $this->prepareFilterData($filter, $fieldList, $forceFullText, $fullTextWhere);
$this->applyFilter($queryBuilder, $filterData);
}
private function composeFullTextSearchData(string $filter): ?FullTextSearchData
{
$composer = $this->fullTextSearchDataComposerFactory->create($this->entityType);
$params = FullTextSearchDataComposerParams::create();
return $composer->compose($filter, $params);
}
private function processFullTextSearch(QueryBuilder $queryBuilder, FullTextSearchData $data): WhereItem
{
$expression = $data->getExpression();
$orderType = self::DEFAULT_FT_ORDER;
$orderTypeMap = [
'combined' => self::FT_ORDER_COMBINED,
'relevance' => self::FT_ORDER_RELEVANCE,
'original' => self::FT_ORDER_ORIGINAL,
];
$mOrderType = $this->metadataProvider->getFullTextSearchOrderType($this->entityType);
if ($mOrderType) {
$orderType = $orderTypeMap[$mOrderType];
}
$previousOrderBy = $queryBuilder->build()->getOrder();
$hasOrderBy = !empty($previousOrderBy);
if (!$hasOrderBy || $orderType === self::FT_ORDER_RELEVANCE) {
$queryBuilder->order([
OrderExpr::create($expression)->withDesc()
]);
} else if ($orderType === self::FT_ORDER_COMBINED) {
$orderExpression =
Expr::round(
Expr::divide($expression, $this->fullTextOrderRelevanceDivider)
);
$newOrderBy = array_merge(
[OrderExpr::create($orderExpression)->withDesc()],
$previousOrderBy
);
$queryBuilder->order($newOrderBy);
}
if ($this->fullTextRelevanceThreshold) {
return Expr::greaterOrEqual(
$expression,
$this->fullTextRelevanceThreshold
);
}
return Expr::notEqual($expression, 0);
}
private function toSkipFullText(string $filter): bool
{
$min = $this->config->getFullTextSearchMinLength();
if ($min === null || strlen($filter) >= $min) {
return false;
}
return
!str_contains($filter, '*') &&
!str_contains($filter, '"') &&
!str_contains($filter, '+') &&
!str_contains($filter, '-');
}
/**
* @return string[]
*/
private function getFieldList(bool $forceFullTextSearch, ?FullTextSearchData $fullTextData): array
{
if ($forceFullTextSearch) {
return [];
}
$fullTextFieldList = $fullTextData ? $fullTextData->getFieldList() : [];
return array_filter(
$this->metadataProvider->getTextFilterAttributeList($this->entityType) ?? self::DEFAULT_ATTRIBUTE_LIST,
fn ($field) => !in_array($field, $fullTextFieldList)
);
}
/**
* @param string[] $fieldList
* @param ?WhereItem $fullTextWhere
*/
private function prepareFilterData(
string $filter,
array $fieldList,
bool $forceFullTextSearch,
?WhereItem $fullTextWhere
): FilterData {
$skipWildcards = false;
if (mb_strpos($filter, '*') !== false) {
$skipWildcards = true;
$filter = str_replace('*', '%', $filter);
}
return FilterData::create($filter, $fieldList)
->withSkipWildcards($skipWildcards)
->withForceFullTextSearch($forceFullTextSearch)
->withFullTextSearchWhereItem($fullTextWhere);
}
private function applyFilter(QueryBuilder $queryBuilder, FilterData $filterData): void
{
$filterObj = $this->filterFactory->create($this->entityType, $this->user);
$filterObj->apply($queryBuilder, $filterData);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text;
use Espo\Core\Utils\Config;
class ConfigProvider
{
private const MIN_LENGTH_FOR_CONTENT_SEARCH = 4;
public function __construct(private Config $config)
{}
/**
* Full-text search min indexed word length.
*
* @internal Do not use.
* @since 8.3.0
*/
public function getFullTextSearchMinLength(): ?int
{
return $this->config->get('fullTextSearchMinLength');
}
public function getMinLengthForContentSearch(): int
{
return $this->config->get('textFilterContainsMinLength') ??
self::MIN_LENGTH_FOR_CONTENT_SEARCH;
}
public function useContainsForVarchar(): bool
{
return $this->config->get('textFilterUseContainsForVarchar') ?? false;
}
public function usePhoneNumberNumericSearch(): bool
{
return $this->config->get('phoneNumberNumericSearch') ?? false;
}
}

View File

@@ -0,0 +1,211 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Select\Text\Filter\Data;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\ORM\Query\Part\Where\OrGroup;
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
use Espo\ORM\Query\Part\Where\Comparison as Cmp;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Entity;
use RuntimeException;
class DefaultFilter implements Filter
{
public function __construct(
private string $entityType,
private MetadataProvider $metadataProvider,
private ConfigProvider $config
) {}
public function apply(QueryBuilder $queryBuilder, Data $data): void
{
$orGroupBuilder = OrGroup::createBuilder();
foreach ($data->getAttributeList() as $attribute) {
$this->applyAttribute($queryBuilder, $orGroupBuilder, $attribute, $data);
}
if ($data->getFullTextSearchWhereItem()) {
$orGroupBuilder->add(
$data->getFullTextSearchWhereItem()
);
}
$orGroup = $orGroupBuilder->build();
if ($orGroup->getItemCount() === 0) {
$queryBuilder->where([Attribute::ID => null]);
return;
}
$queryBuilder->where($orGroup);
}
/**
* @todo AttributeFilterFactory.
*/
private function applyAttribute(
QueryBuilder $queryBuilder,
OrGroupBuilder $orGroupBuilder,
string $attribute,
Data $data
): void {
$filter = $data->getFilter();
$skipWildcards = $data->skipWildcards();
$attributeType = $this->getAttributeTypeAndApplyJoin($queryBuilder, $attribute);
if ($attributeType === Entity::INT) {
if (is_numeric($filter)) {
$orGroupBuilder->add(
Cmp::equal(
Expr::column($attribute),
intval($filter)
)
);
}
return;
}
if (
!str_contains($attribute, '.') &&
$this->metadataProvider->getFieldType($this->entityType, $attribute) === FieldType::EMAIL &&
str_contains($filter, ' ')
) {
return;
}
if (
!str_contains($attribute, '.') &&
$this->metadataProvider->getFieldType($this->entityType, $attribute) === FieldType::PHONE
) {
if (!preg_match("#[0-9()\-+% ]+$#", $filter)) {
return;
}
if ($this->config->usePhoneNumberNumericSearch()) {
$attribute = $attribute . 'Numeric';
$filter = preg_replace('/[^0-9%]/', '', $filter);
}
if (!$filter) {
return;
}
}
$expression = $filter;
if (!$skipWildcards) {
$expression = $this->checkWhetherToUseContains($attribute, $filter, $attributeType) ?
'%' . $filter . '%' :
$filter . '%';
}
$expression = addslashes($expression);
$orGroupBuilder->add(
Cmp::like(
Expr::column($attribute),
$expression
)
);
}
private function getAttributeTypeAndApplyJoin(QueryBuilder $queryBuilder, string $attribute): string
{
if (str_contains($attribute, '.')) {
[$link, $foreignField] = explode('.', $attribute);
$foreignEntityType = $this->metadataProvider->getRelationEntityType($this->entityType, $link);
if (!$foreignEntityType) {
throw new RuntimeException("Bad relation in text filter field '$attribute'.");
}
if ($this->metadataProvider->getRelationType($this->entityType, $link) === Entity::HAS_MANY) {
$queryBuilder->distinct();
}
$queryBuilder->leftJoin($link);
return $this->metadataProvider->getAttributeType($foreignEntityType, $foreignField);
}
$attributeType = $this->metadataProvider->getAttributeType($this->entityType, $attribute);
if ($attributeType === Entity::FOREIGN) {
$link = $this->metadataProvider->getAttributeRelationParam($this->entityType, $attribute);
if ($link) {
$queryBuilder->leftJoin($link);
}
}
return $attributeType;
}
private function checkWhetherToUseContains(string $attribute, string $filter, string $attributeType): bool
{
if (mb_strlen($filter) < $this->config->getMinLengthForContentSearch()) {
return false;
}
if ($attributeType === Entity::TEXT) {
return true;
}
if (
in_array(
$attribute,
$this->metadataProvider->getUseContainsAttributeList($this->entityType)
)
) {
return true;
}
if (
$attributeType === Entity::VARCHAR &&
$this->config->useContainsForVarchar()
) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text;
use Espo\ORM\Query\SelectBuilder;
use Espo\Core\Select\Text\Filter\Data;
interface Filter
{
public function apply(SelectBuilder $queryBuilder, Data $data): void;
}

View File

@@ -0,0 +1,133 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text\Filter;
use Espo\ORM\Query\Part\WhereItem;
/**
* Immutable.
*/
class Data
{
private string $filter;
/** @var string[] */
private array $attributeList;
private bool $skipWildcards = false;
private ?WhereItem $fullTextSearchWhereItem = null;
private bool $forceFullTextSearch = false;
/**
* @param string[] $attributeList
*/
public function __construct(string $filter, array $attributeList)
{
$this->filter = $filter;
$this->attributeList = $attributeList;
}
/**
* @param string[] $attributeList
*/
public static function create(string $filter, array $attributeList): self
{
return new self($filter, $attributeList);
}
public function withFilter(string $filter): self
{
$obj = clone $this;
$obj->filter = $filter;
return $obj;
}
/**
* @param string[] $attributeList
*/
public function withAttributeList(array $attributeList): self
{
$obj = clone $this;
$obj->attributeList = $attributeList;
return $obj;
}
public function withSkipWildcards(bool $skipWildcards = true): self
{
$obj = clone $this;
$obj->skipWildcards = $skipWildcards;
return $obj;
}
public function withForceFullTextSearch(bool $forceFullTextSearch = true): self
{
$obj = clone $this;
$obj->forceFullTextSearch = $forceFullTextSearch;
return $obj;
}
public function withFullTextSearchWhereItem(?WhereItem $fullTextSearchWhereItem): self
{
$obj = clone $this;
$obj->fullTextSearchWhereItem = $fullTextSearchWhereItem;
return $obj;
}
public function getFilter(): string
{
return $this->filter;
}
/**
* @return string[]
*/
public function getAttributeList(): array
{
return $this->attributeList;
}
public function skipWildcards(): bool
{
return $this->skipWildcards;
}
public function forceFullTextSearch(): bool
{
return $this->forceFullTextSearch;
}
public function getFullTextSearchWhereItem(): ?WhereItem
{
return $this->fullTextSearchWhereItem;
}
}

View File

@@ -0,0 +1,69 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text;
use Espo\Core\Utils\Metadata;
use Espo\Core\InjectableFactory;
use Espo\Entities\User;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\Binding\ContextualBinder;
class FilterFactory
{
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory
) {}
public function create(string $entityType, User $user): Filter
{
/** @var class-string<Filter> $className */
$className = $this->metadata->get(['selectDefs', $entityType, 'textFilterClassName']) ??
DefaultFilter::class;
$bindingContainer = BindingContainerBuilder::create()
->bindInstance(User::class, $user)
->inContext(
$className,
function (ContextualBinder $binder) use ($entityType) {
$binder->bindValue('$entityType', $entityType);
}
)
->inContext(
DefaultFilter::class,
function (ContextualBinder $binder) use ($entityType) {
$binder->bindValue('$entityType', $entityType);
}
)
->build();
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
}

View File

@@ -0,0 +1,58 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text;
/**
* Immutable.
*/
class FilterParams
{
private bool $noFullTextSearch = false;
private function __construct() {}
public static function create(): self
{
return new self();
}
public function withNoFullTextSearch(bool $noFullTextSearch = true): self
{
$obj = clone $this;
$obj->noFullTextSearch = $noFullTextSearch;
return $obj;
}
public function noFullTextSearch(): bool
{
return $this->noFullTextSearch;
}
}

View File

@@ -0,0 +1,92 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text\FullTextSearch;
use Espo\ORM\Query\Part\Expression;
use InvalidArgumentException;
/**
* Immutable.
*/
class Data
{
/** @var string[] */
private array $fieldList;
/** @var string[] */
private array $columnList;
/** @var Mode::* $mode */
private string $mode;
/**
* @param string[] $fieldList
* @param string[] $columnList
* @param Mode::* $mode
*/
public function __construct(private Expression $expression, array $fieldList, array $columnList, string $mode)
{
$this->fieldList = $fieldList;
$this->columnList = $columnList;
$this->mode = $mode;
if (!in_array($mode, [Mode::NATURAL_LANGUAGE, Mode::BOOLEAN])) {
throw new InvalidArgumentException("Bad mode.");
}
}
public function getExpression(): Expression
{
return $this->expression;
}
/**
* @return string[]
*/
public function getFieldList(): array
{
return $this->fieldList;
}
/**
* @return string[]
*/
public function getColumnList(): array
{
return $this->columnList;
}
/**
* @return Mode::*
*/
public function getMode(): string
{
return $this->mode;
}
}

View File

@@ -0,0 +1,37 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text\FullTextSearch;
use Espo\Core\Select\Text\FullTextSearch\DataComposer\Params;
interface DataComposer
{
public function compose(string $filter, Params $params): ?Data;
}

View File

@@ -0,0 +1,43 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text\FullTextSearch\DataComposer;
/**
* Immutable.
*/
class Params
{
private function __construct() {}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,62 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text\FullTextSearch;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
class DataComposerFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata
) {}
public function create(string $entityType): DataComposer
{
$className = $this->getClassName($entityType);
return $this->injectableFactory->createWith($className, [
'entityType' => $entityType,
]);
}
/**
* @return class-string<DataComposer>
*/
private function getClassName(string $entityType): string
{
return
$this->metadata->get([
'selectDefs', $entityType, 'fullTextSearchDataComposerClassName'
]) ??
DefaultDataComposer::class;
}
}

View File

@@ -0,0 +1,168 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text\FullTextSearch;
use Espo\Core\Utils\Config;
use Espo\Core\Select\Text\MetadataProvider;
use Espo\Core\Select\Text\FullTextSearch\DataComposer\Params;
use Espo\ORM\Query\Part\Expression\Util as ExpressionUtil;
use Espo\ORM\Query\Part\Expression;
class DefaultDataComposer implements DataComposer
{
/** @var array<Mode::*, string>*/
private array $functionMap = [
Mode::BOOLEAN => 'MATCH_BOOLEAN',
Mode::NATURAL_LANGUAGE => 'MATCH_NATURAL_LANGUAGE',
];
public function __construct(
private string $entityType,
private Config $config,
private MetadataProvider $metadataProvider
) {}
public function compose(string $filter, Params $params): ?Data
{
if ($this->config->get('fullTextSearchDisabled')) {
return null;
}
$columnList = $this->metadataProvider->getFullTextSearchColumnList($this->entityType) ?? [];
if (!count($columnList)) {
return null;
}
$fieldList = [];
foreach ($this->getTextFilterFieldList() as $field) {
if (str_contains($field, '.')) {
continue;
}
if ($this->metadataProvider->isFieldNotStorable($this->entityType, $field)) {
continue;
}
if (!$this->metadataProvider->isFullTextSearchSupportedForField($this->entityType, $field)) {
continue;
}
$fieldList[] = $field;
}
if (!count($fieldList)) {
return null;
}
$preparedFilter = $this->prepareFilter($filter, $params);
$mode = Mode::BOOLEAN;
if (
mb_strpos($preparedFilter, ' ') === false &&
mb_strpos($preparedFilter, '+') === false &&
mb_strpos($preparedFilter, ' -') === false &&
mb_strpos($preparedFilter, '-') !== 0 &&
mb_strpos($preparedFilter, '*') === false
) {
$mode = Mode::NATURAL_LANGUAGE;
}
if ($mode === Mode::BOOLEAN) {
$preparedFilter = str_replace('@', '*', $preparedFilter);
}
$argumentList = array_merge(
array_map(fn ($item) => Expression::column($item), $columnList),
[$preparedFilter]
);
$function = $this->functionMap[$mode];
$expression = ExpressionUtil::composeFunction($function, ...$argumentList);
return new Data(
$expression,
$fieldList,
$columnList,
$mode
);
}
private function prepareFilter(string $filter, Params $params): string
{
$filter = str_replace('%', '*', $filter);
$filter = str_replace(['(', ')'], '', $filter);
$filter = str_replace('"*', '"', $filter);
$filter = str_replace('*"', '"', $filter);
while (str_contains($filter, '**')) {
$filter = trim(
str_replace('**', '*', $filter)
);
}
while (mb_substr($filter, -2) === ' *') {
$filter = trim(
mb_substr($filter, 0, mb_strlen($filter) - 2)
);
}
$filter = str_replace(['+-', '--', '-+', '++', '+*', '-*'], '', $filter);
while (str_contains($filter, '+ ')) {
$filter = str_replace('+ ', '', $filter);
}
while (str_contains($filter, '- ')) {
$filter = str_replace('- ', '', $filter);
}
while (in_array(substr($filter, -1), ['-', '+'])) {
$filter = substr($filter, 0, -1);
}
if ($filter === '*') {
$filter = '';
}
return $filter;
}
/**
* @return string[]
*/
private function getTextFilterFieldList(): array
{
return $this->metadataProvider->getTextFilterAttributeList($this->entityType) ?? ['name'];
}
}

View File

@@ -0,0 +1,36 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text\FullTextSearch;
class Mode
{
public const NATURAL_LANGUAGE = 'NATURAL_LANGUAGE';
public const BOOLEAN = 'BOOLEAN';
}

View File

@@ -0,0 +1,156 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Text;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\FieldParam;
class MetadataProvider
{
private Defs $ormDefs;
public function __construct(private Metadata $metadata, Defs $ormDefs)
{
$this->ormDefs = $ormDefs;
}
public function getFullTextSearchOrderType(string $entityType): ?string
{
return $this->metadata->get([
'entityDefs', $entityType, 'collection', 'fullTextSearchOrderType'
]);
}
/**
* @return string[]|null
*/
public function getTextFilterAttributeList(string $entityType): ?array
{
return $this->metadata->get([
'entityDefs', $entityType, 'collection', 'textFilterFields'
]);
}
public function isFieldNotStorable(string $entityType, string $field): bool
{
return (bool) $this->metadata->get([
'entityDefs', $entityType, 'fields', $field, Defs\Params\FieldParam::NOT_STORABLE
]);
}
public function isFullTextSearchSupportedForField(string $entityType, string $field): bool
{
$fieldType = $this->metadata->get([
'entityDefs', $entityType, 'fields', $field, FieldParam::TYPE
]);
return (bool) $this->metadata->get([
'fields', $fieldType, 'fullTextSearch'
]);
}
public function hasFullTextSearch(string $entityType): bool
{
return (bool) $this->metadata->get([
'entityDefs', $entityType, 'collection', 'fullTextSearch'
]);
}
/**
* @return string[]
*/
public function getUseContainsAttributeList(string $entityType): array
{
return $this->metadata->get([
'selectDefs', $entityType, 'textFilterUseContainsAttributeList'
]) ?? [];
}
/**
* @return string[]|null
*/
public function getFullTextSearchColumnList(string $entityType): ?array
{
return $this->ormDefs
->getEntity($entityType)
->getParam('fullTextSearchColumnList');
}
public function getRelationType(string $entityType, string $link): string
{
return $this->ormDefs
->getEntity($entityType)
->getRelation($link)
->getType();
}
public function getAttributeType(string $entityType, string $attribute): string
{
return $this->ormDefs
->getEntity($entityType)
->getAttribute($attribute)
->getType();
}
public function getFieldType(string $entityType, string $field): ?string
{
$entityDefs = $this->ormDefs->getEntity($entityType);
if (!$entityDefs->hasField($field)) {
return null;
}
return $entityDefs->getField($field)->getType();
}
public function getRelationEntityType(string $entityType, string $link): ?string
{
$relationDefs = $this->ormDefs
->getEntity($entityType)
->getRelation($link);
if (!$relationDefs->hasForeignEntityType()) {
return null;
}
return $relationDefs->getForeignEntityType();
}
public function getAttributeRelationParam(string $entityType, string $attribute): ?string
{
return $this->ormDefs
->getEntity($entityType)
->getAttribute($attribute)
->getParam(AttributeParam::RELATION);
}
}

View File

@@ -0,0 +1,74 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\Entities\User;
class Applier
{
public function __construct(
private string $entityType,
private User $user,
private ConverterFactory $converterFactory,
private CheckerFactory $checkerFactory
) {}
/**
* @throws BadRequest
* @throws Forbidden
*/
public function apply(QueryBuilder $queryBuilder, WhereItem $whereItem, Params $params): void
{
$this->check($whereItem, $params);
$converter = $this->converterFactory->create($this->entityType, $this->user);
$convertedParams = new Converter\Params(useSubQueryIfMany: true);
$queryBuilder->where(
$converter->convert($queryBuilder, $whereItem, $convertedParams)
);
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function check(Item $whereItem, Params $params): void
{
$checker = $this->checkerFactory->create($this->entityType, $this->user);
$checker->check($whereItem, $params);
}
}

View File

@@ -0,0 +1,307 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Acl;
use Espo\Core\Select\Where\Item\Type;
use Espo\Entities\Team;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\QueryComposer\Util;
use Espo\ORM\QueryComposer\Util as QueryUtil;
use Espo\ORM\EntityManager;
use Espo\ORM\Entity;
use Espo\ORM\BaseEntity;
/**
* Checks Where parameters. Throws an exception if anything not allowed is met.
*
* @todo Check read access to foreign entity for belongs-to, belongs-to-parent, has-one.
*/
class Checker
{
private ?Entity $seed = null;
private const TYPE_IN_CATEGORY = 'inCategory';
private const TYPE_IS_USER_FROM_TEAMS = 'isUserFromTeams';
/** @var string[] */
private $nestingTypeList = [
Type::OR,
Type::AND,
Type::NOT,
Type::SUBQUERY_IN,
Type::SUBQUERY_NOT_IN,
];
/** @var string[] */
private $subQueryTypeList = [
Type::SUBQUERY_IN,
Type::SUBQUERY_NOT_IN,
Type::NOT,
];
/** @var string[] */
private $linkTypeList = [
self::TYPE_IN_CATEGORY,
self::TYPE_IS_USER_FROM_TEAMS,
Type::IS_LINKED_WITH,
Type::IS_NOT_LINKED_WITH,
Type::IS_LINKED_WITH_ALL,
Type::IS_LINKED_WITH_ANY,
Type::IS_LINKED_WITH_NONE,
];
/** @var string[] */
private $linkWithIdsTypeList = [
self::TYPE_IN_CATEGORY,
self::TYPE_IS_USER_FROM_TEAMS,
Type::IS_LINKED_WITH,
Type::IS_NOT_LINKED_WITH,
Type::IS_LINKED_WITH_ALL,
];
public function __construct(
private string $entityType,
private EntityManager $entityManager,
private Acl $acl,
) {}
/**
* Check.
*
* @throws Forbidden
* @throws BadRequest
*/
public function check(Item $item, Params $params): void
{
$this->checkItem($item, $params);
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function checkItem(Item $item, Params $params): void
{
$type = $item->getType();
$attribute = $item->getAttribute();
$value = $item->getValue();
$forbidComplexExpressions = $params->forbidComplexExpressions();
$checkWherePermission = $params->applyPermissionCheck();
if ($forbidComplexExpressions) {
if (in_array($type, $this->subQueryTypeList)) {
throw new Forbidden("Sub-queries are forbidden in where.");
}
}
if ($attribute && $forbidComplexExpressions) {
if (QueryUtil::isComplexExpression($attribute)) {
throw new Forbidden("Complex expressions are forbidden in where.");
}
}
if ($attribute) {
$argumentList = Util::getAllAttributesFromComplexExpression($attribute);
foreach ($argumentList as $argument) {
$this->checkAttributeExistence($argument, $type);
if ($checkWherePermission) {
$this->checkAttributePermission($argument, $type, $value);
}
}
}
if (in_array($type, $this->nestingTypeList) && is_array($value)) {
foreach ($value as $subItem) {
$this->checkItem(Item::fromRaw($subItem), $params);
}
}
}
/**
* @throws BadRequest
*/
private function checkAttributeExistence(string $attribute, string $type): void
{
if (str_contains($attribute, '.')) {
// @todo Check existence of foreign attributes.
return;
}
if (in_array($type, $this->linkTypeList)) {
if (!$this->getSeed()->hasRelation($attribute)) {
throw new BadRequest("Not existing relation '$attribute' in where.");
}
return;
}
if (!$this->getSeed()->hasAttribute($attribute)) {
throw new BadRequest("Not existing attribute '$attribute' in where.");
}
}
/**
* @throws Forbidden
* @throws BadRequest
*/
private function checkAttributePermission(string $attribute, string $type, mixed $value): void
{
$entityType = $this->entityType;
if (str_contains($attribute, '.')) {
[$link, $attribute] = explode('.', $attribute);
if (!$this->getSeed()->hasRelation($link)) {
// TODO allow alias
throw new Forbidden("Bad relation '$link' in where.");
}
$foreignEntityType = $this->getRelationEntityType($this->getSeed(), $link);
if (!$foreignEntityType) {
throw new Forbidden("Bad relation '$link' in where.");
}
if (
!$this->acl->checkScope($foreignEntityType) ||
in_array($link, $this->acl->getScopeForbiddenLinkList($entityType))
) {
throw new Forbidden("Forbidden relation '$link' in where.");
}
if (in_array($attribute, $this->acl->getScopeForbiddenAttributeList($foreignEntityType))) {
throw new Forbidden("Forbidden attribute '$link.$attribute' in where.");
}
return;
}
if (in_array($type, $this->linkTypeList)) {
$this->checkLink($type, $entityType, $attribute, $value);
return;
}
if (in_array($attribute, $this->acl->getScopeForbiddenAttributeList($entityType))) {
throw new Forbidden("Forbidden attribute '$attribute' in where.");
}
}
private function getSeed(): Entity
{
$this->seed ??= $this->entityManager->getNewEntity($this->entityType);
return $this->seed;
}
private function getRelationEntityType(Entity $entity, string $relation): mixed
{
if ($entity instanceof BaseEntity) {
return $entity->getRelationParam($relation, RelationParam::ENTITY);
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType());
if (!$entityDefs->hasRelation($relation)) {
return null;
}
return $entityDefs->getRelation($relation)->getParam(RelationParam::ENTITY);
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function checkLink(string $type, string $entityType, string $link, mixed $value): void
{
if (!$this->getSeed()->hasRelation($link)) {
throw new Forbidden("Bad relation '$link' in where.");
}
$foreignEntityType = $this->getRelationEntityType($this->getSeed(), $link);
if (!$foreignEntityType) {
throw new Forbidden("Bad relation '$link' in where.");
}
if ($type === self::TYPE_IS_USER_FROM_TEAMS) {
$foreignEntityType = Team::ENTITY_TYPE;
}
if (
in_array($link, $this->acl->getScopeForbiddenFieldList($entityType)) ||
!$this->acl->checkScope($foreignEntityType) ||
in_array($link, $this->acl->getScopeForbiddenLinkList($entityType))
) {
throw new Forbidden("Forbidden relation '$link' in where.");
}
if (!in_array($type, $this->linkWithIdsTypeList)) {
return;
}
if ($value === null) {
return;
}
if (!is_array($value)) {
$value = [$value];
}
foreach ($value as $it) {
if (!is_string($it)) {
throw new BadRequest("Bad where item. Non-string ID.");
}
}
// @todo Use the Select Builder instead. Check the result count equal the input IDs count.
foreach ($value as $id) {
$entity = $this->entityManager->getEntityById($foreignEntityType, $id);
if (!$entity) {
throw new Forbidden("Record '$foreignEntityType' `$id` not found.");
}
if (!$this->acl->checkEntityRead($entity)) {
throw new Forbidden("No access to '$foreignEntityType' `$id`.");
}
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Acl\Exceptions\NotAvailable;
use Espo\Entities\User;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Acl\UserAclManagerProvider;
use RuntimeException;
class CheckerFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private UserAclManagerProvider $userAclManagerProvider
) {}
public function create(string $entityType, User $user): Checker
{
try {
$acl = $this->userAclManagerProvider
->get($user)
->createUserAcl($user);
} catch (NotAvailable $e) {
throw new RuntimeException($e->getMessage());
}
return $this->injectableFactory->createWith(Checker::class, [
'entityType' => $entityType,
'acl' => $acl,
]);
}
}

View File

@@ -0,0 +1,147 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Select\Where\Item\Type;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Part\Where\Comparison;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use InvalidArgumentException;
use RuntimeException;
/**
* Converts a search where (passed from front-end) to a where clause (for ORM).
*/
class Converter
{
public function __construct(
private ItemConverter $itemConverter,
private Scanner $scanner
) {}
/**
* @throws BadRequest
*/
public function convert(QueryBuilder $queryBuilder, Item $item, ?Converter\Params $params = null): WhereItem
{
if ($params && $params->useSubQueryIfMany() && $this->hasRelatedMany($queryBuilder, $item)) {
return $this->convertSubQuery($queryBuilder, $item);
}
$whereClause = [];
foreach ($this->itemToList($item) as $subItemRaw) {
try {
$subItem = Item::fromRaw($subItemRaw);
} catch (InvalidArgumentException $e) {
throw new BadRequest($e->getMessage());
}
$part = $this->processItem($queryBuilder, $subItem);
if ($part === []) {
continue;
}
$whereClause[] = $part;
}
$this->scanner->apply($queryBuilder, $item);
return WhereClause::fromRaw($whereClause);
}
private function hasRelatedMany(QueryBuilder $queryBuilder, Item $item): bool
{
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new RuntimeException("No 'from' in queryBuilder.");
}
return $this->scanner->hasRelatedMany($entityType, $item);
}
/**
* @return array<int|string, mixed>
* @throws BadRequest
*/
private function itemToList(Item $item): array
{
if ($item->getType() !== Type::AND) {
return [
$item->getRaw(),
];
}
$list = $item->getValue();
if (!is_array($list)) {
throw new BadRequest("Bad where item value.");
}
return $list;
}
/**
* @return array<int|string, mixed>
* @throws BadRequest
*/
private function processItem(QueryBuilder $queryBuilder, Item $item): array
{
return $this->itemConverter->convert($queryBuilder, $item)->getRaw();
}
/**
* @throws BadRequest
*/
private function convertSubQuery(QueryBuilder $queryBuilder, Item $item): Comparison
{
$entityType = $queryBuilder->build()->getFrom() ?? throw new RuntimeException();
$subQueryBuilder = QueryBuilder::create()
->from($entityType)
->select(Attribute::ID);
$subQueryBuilder->where(
$this->convert($subQueryBuilder, $item)
);
return Cond::in(
Expr::column(Attribute::ID),
$subQueryBuilder->build()
);
}
}

View File

@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where\Converter;
/**
* Where converter parameters.
*
* Immutable.
* @since 9.0.0
*/
class Params
{
/**
* @param bool $useSubQueryIfMany To use a sub-query if at least one has-many relation appears in a where clause.
*/
public function __construct(
readonly private bool $useSubQueryIfMany = false,
) {}
/**
* To use a sub-query if at least one has-many relation appears in a where clause.
*/
public function useSubQueryIfMany(): bool
{
return $this->useSubQueryIfMany;
}
}

View File

@@ -0,0 +1,158 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Binding\Binder;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingData;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
class ConverterFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Metadata $metadata
) {}
public function create(string $entityType, User $user): Converter
{
$dateTimeItemTransformer = $this->createDateTimeItemTransformer($entityType, $user);
$itemConverter = $this->createItemConverter($entityType, $user, $dateTimeItemTransformer);
$className = $this->getConverterClassName($entityType);
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user);
$binder
->for($className)
->bindValue('$entityType', $entityType)
->bindInstance(ItemConverter::class, $itemConverter);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
private function createDateTimeItemTransformer(string $entityType, User $user): DateTimeItemTransformer
{
$className = $this->getDateTimeItemTransformerClassName($entityType);
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder->bindInstance(User::class, $user);
$binder
->for($className)
->bindValue('$entityType', $entityType);
$binder
->for(DefaultDateTimeItemTransformer::class)
->bindValue('$entityType', $entityType);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
private function createItemConverter(
string $entityType,
User $user,
DateTimeItemTransformer $dateTimeItemTransformer
): ItemConverter {
$className = $this->getItemConverterClassName($entityType);
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user);
$binder
->for($className)
->bindValue('$entityType', $entityType)
->bindInstance(DateTimeItemTransformer::class, $dateTimeItemTransformer);
$binder
->for(ItemGeneralConverter::class)
->bindValue('$entityType', $entityType)
->bindInstance(DateTimeItemTransformer::class, $dateTimeItemTransformer);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
/**
* @return class-string<Converter>
*/
private function getConverterClassName(string $entityType): string
{
$className = $this->metadata->get(['selectDefs', $entityType, 'whereConverterClassName']);
if ($className) {
return $className;
}
return Converter::class;
}
/**
* @return class-string<ItemGeneralConverter>
*/
private function getItemConverterClassName(string $entityType): string
{
$className = $this->metadata->get(['selectDefs', $entityType, 'whereItemConverterClassName']);
if ($className) {
return $className;
}
return ItemGeneralConverter::class;
}
/**
* @return class-string<DateTimeItemTransformer>
*/
private function getDateTimeItemTransformerClassName(string $entityType): string
{
$className = $this->metadata
->get(['selectDefs', $entityType, 'whereDateTimeItemTransformerClassName']);
if ($className) {
return $className;
}
return DefaultDateTimeItemTransformer::class;
}
}

View File

@@ -0,0 +1,46 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Exceptions\BadRequest;
/**
* Transforms date-time where item. Applies timezone. *
* Implementing a custom transformer should not be considered future-proof.
*
* @internal
*/
interface DateTimeItemTransformer
{
/**
* @throws BadRequest
*/
public function transform(Item $item): Item;
}

View File

@@ -0,0 +1,519 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Core\Utils\Config;
use Espo\Core\Select\Where\Item\Type;
use DateTime;
use DateTimeZone;
use DateInterval;
use Exception;
use RuntimeException;
class DefaultDateTimeItemTransformer implements DateTimeItemTransformer
{
public function __construct(
private Config $config,
private Config\ApplicationConfig $applicationConfig,
) {}
/**
* @throws BadRequest
*/
public function transform(Item $item): Item
{
$format = DateTimeUtil::SYSTEM_DATE_TIME_FORMAT;
$type = $item->getType();
$value = $item->getValue();
$attribute = $item->getAttribute();
$data = $item->getData();
if (!$data instanceof Item\Data\DateTime) {
throw new BadRequest("Bad where item.");
}
$timeZone = $data->getTimeZone() ?? $this->applicationConfig->getTimeZone();
if (!$attribute) {
throw new BadRequest("Bad datetime where item. Empty 'attribute'.");
}
if (!$type) {
throw new BadRequest("Bad datetime where item. Empty 'type'.");
}
if (
empty($value) &&
in_array(
$type,
[
Type::ON,
Type::BEFORE,
Type::AFTER,
]
)
) {
throw new BadRequest("Bad where item. Empty value.");
}
$where = [
'attribute' => $attribute,
];
try {
$dt = new DateTime('now', new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad timezone");
}
switch ($type) {
case Type::TODAY:
$where['type'] = Type::BETWEEN;
$dt->setTime(0, 0);
$dtTo = clone $dt;
$dtTo->modify('+1 day -1 second');
$dt->setTimezone(new DateTimeZone('UTC'));
$dtTo->setTimezone(new DateTimeZone('UTC'));
$from = $dt->format($format);
$to = $dtTo->format($format);
$where['value'] = [$from, $to];
break;
case Type::PAST:
$where['type'] = Type::LESS_THAN_OR_EQUALS;
$dt->setTimezone(new DateTimeZone('UTC'));
$where['value'] = $dt->format($format);
break;
case Type::FUTURE:
$where['type'] = Type::AFTER;
$dt->setTimezone(new DateTimeZone('UTC'));
$where['value'] = $dt->format($format);
break;
case Type::LAST_SEVEN_DAYS:
$where['type'] = Type::BETWEEN;
$dtFrom = clone $dt;
$dtTo = clone $dt;
$dtTo->setTimezone(new DateTimeZone('UTC'));
$to = $dtTo->format($format);
$dtFrom->modify('-7 day');
$dtFrom->setTime(0, 0);
$dtFrom->setTimezone(new DateTimeZone('UTC'));
$from = $dtFrom->format($format);
$where['value'] = [$from, $to];
break;
case Type::LAST_X_DAYS:
$where['type'] = Type::BETWEEN;
$dtFrom = clone $dt;
$dtTo = clone $dt;
$dtTo->setTimezone(new DateTimeZone('UTC'));
$to = $dtTo->format($format);
$number = strval(intval($value));
$dtFrom->modify('-'.$number.' day');
$dtFrom->setTime(0, 0);
$dtFrom->setTimezone(new DateTimeZone('UTC'));
$from = $dtFrom->format($format);
$where['value'] = [$from, $to];
break;
case Type::NEXT_X_DAYS:
$where['type'] = Type::BETWEEN;
$dtTo = clone $dt;
$dt->setTimezone(new DateTimeZone('UTC'));
$from = $dt->format($format);
$number = strval(intval($value));
$dtTo->modify('+'.$number.' day');
$dtTo->setTime(24, 59, 59);
$dtTo->setTimezone(new DateTimeZone('UTC'));
$to = $dtTo->format($format);
$where['value'] = [$from, $to];
break;
case Type::OLDER_THAN_X_DAYS:
$where['type'] = Type::BEFORE;
$number = strval(intval($value));
$dt->modify("-$number day");
$dt->setTime(0, 0);
$dt->setTimezone(new DateTimeZone('UTC'));
$where['value'] = $dt->format($format);
break;
case Type::AFTER_X_DAYS:
$where['type'] = Type::GREATER_THAN_OR_EQUALS;
$number = strval(intval($value));
$dt->modify("+$number day");
$dt->setTime(0, 0);
$dt->setTimezone(new DateTimeZone('UTC'));
$where['value'] = $dt->format($format);
break;
case Type::ON:
$where['type'] = Type::BETWEEN;
try {
$dt = new DateTime($value, new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad date value or timezone.");
}
$dtTo = clone $dt;
if (strlen($value) <= 10) {
$dtTo->modify('+1 day -1 second');
}
$dt->setTimezone(new DateTimeZone('UTC'));
$dtTo->setTimezone(new DateTimeZone('UTC'));
$from = $dt->format($format);
$to = $dtTo->format($format);
$where['value'] = [$from, $to];
break;
case Type::BEFORE:
$where['type'] = Type::BEFORE;
try {
$dt = new DateTime($value, new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad date value or timezone.");
}
$dt->setTimezone(new DateTimeZone('UTC'));
$where['value'] = $dt->format($format);
break;
case Type::AFTER:
$where['type'] = Type::AFTER;
try {
$dt = new DateTime($value, new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad date value or timezone.");
}
if (strlen($value) <= 10) {
$dt->modify('+1 day -1 second');
}
$dt->setTimezone(new DateTimeZone('UTC'));
$where['value'] = $dt->format($format);
break;
case Type::BETWEEN:
$where['type'] = Type::BETWEEN;
if (!is_array($value) || count($value) < 2) {
throw new BadRequest("Bad where item. Bad value.");
}
try {
$dt = new DateTime($value[0], new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad date value or timezone.");
}
$dt->setTimezone(new DateTimeZone('UTC'));
$from = $dt->format($format);
try {
$dt = new DateTime($value[1], new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad date value or timezone.");
}
$dt->setTimezone(new DateTimeZone('UTC'));
if (strlen($value[1]) <= 10) {
$dt->modify('+1 day -1 second');
}
$to = $dt->format($format);
$where['value'] = [$from, $to];
break;
case Type::CURRENT_MONTH:
case Type::LAST_MONTH:
case Type::NEXT_MONTH:
$where['type'] = Type::BETWEEN;
$dtFrom = $dt->modify('first day of this month')->setTime(0, 0);
if ($type == Type::LAST_MONTH) {
$dtFrom->modify('-1 month');
} else if ($type == Type::NEXT_MONTH) {
$dtFrom->modify('+1 month');
}
$dtTo = clone $dtFrom;
$dtTo->modify('+1 month')->modify('-1 second');
$dtFrom->setTimezone(new DateTimeZone('UTC'));
$dtTo->setTimezone(new DateTimeZone('UTC'));
$where['value'] = [$dtFrom->format($format), $dtTo->format($format)];
break;
case Type::CURRENT_QUARTER:
case Type::LAST_QUARTER:
$where['type'] = Type::BETWEEN;
try {
$dt = new DateTime('now', new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad timezone.");
}
$quarter = ceil($dt->format('m') / 3);
$dtFrom = clone $dt;
$dtFrom->modify('first day of January this year')->setTime(0, 0);
if ($type === Type::LAST_QUARTER) {
$quarter--;
if ($quarter == 0) {
$quarter = 4;
$dtFrom->modify('-1 year');
}
}
try {
$dtFrom->add(new DateInterval('P' . (($quarter - 1) * 3) . 'M'));
} catch (Exception) {
throw new RuntimeException();
}
$dtTo = clone $dtFrom;
$dtTo->add(new DateInterval('P3M'))->modify('-1 second');
$dtFrom->setTimezone(new DateTimeZone('UTC'));
$dtTo->setTimezone(new DateTimeZone('UTC'));
$where['value'] = [
$dtFrom->format($format),
$dtTo->format($format),
];
break;
case Type::CURRENT_YEAR:
case Type::LAST_YEAR:
$where['type'] = Type::BETWEEN;
try {
$dtFrom = new DateTime('now', new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad timezone.");
}
$dtFrom->modify('first day of January this year')->setTime(0, 0);
if ($type == Type::LAST_YEAR) {
$dtFrom->modify('-1 year');
}
$dtTo = clone $dtFrom;
$dtTo = $dtTo->modify('+1 year')->modify('-1 second');
$dtFrom->setTimezone(new DateTimeZone('UTC'));
$dtTo->setTimezone(new DateTimeZone('UTC'));
$where['value'] = [
$dtFrom->format($format),
$dtTo->format($format),
];
break;
case Type::CURRENT_FISCAL_YEAR:
case Type::LAST_FISCAL_YEAR:
$where['type'] = Type::BETWEEN;
try {
$dtToday = new DateTime('now', new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad timezone.");
}
$dt = clone $dtToday;
$fiscalYearShift = $this->config->get('fiscalYearShift', 0);
$dt
->modify('first day of January this year')
->modify('+' . $fiscalYearShift . ' months')
->setTime(0, 0);
if (intval($dtToday->format('m')) < $fiscalYearShift + 1) {
$dt->modify('-1 year');
}
if ($type === Type::LAST_FISCAL_YEAR) {
$dt->modify('-1 year');
}
$dtFrom = clone $dt;
$dtTo = clone $dt;
$dtTo = $dtTo->modify('+1 year')->modify('-1 second');
$dtFrom->setTimezone(new DateTimeZone('UTC'));
$dtTo->setTimezone(new DateTimeZone('UTC'));
$where['value'] = [
$dtFrom->format($format),
$dtTo->format($format),
];
break;
case Type::CURRENT_FISCAL_QUARTER:
case Type::LAST_FISCAL_QUARTER:
$where['type'] = Type::BETWEEN;
try {
$dtToday = new DateTime('now', new DateTimeZone($timeZone));
} catch (Exception) {
throw new BadRequest("Bad timezone.");
}
$dt = clone $dtToday;
$fiscalYearShift = $this->config->get('fiscalYearShift', 0);
$dt
->modify('first day of January this year')
->modify('+' . $fiscalYearShift . ' months')
->setTime(0, 0);
$month = intval($dtToday->format('m'));
$quarterShift = floor(($month - $fiscalYearShift - 1) / 3);
if ($quarterShift) {
if ($quarterShift >= 0) {
try {
$dt->add(new DateInterval('P' . ($quarterShift * 3) . 'M'));
} catch (Exception) {
throw new RuntimeException();
}
} else {
$quarterShift *= -1;
try {
$dt->sub(new DateInterval('P' . ($quarterShift * 3) . 'M'));
} catch (Exception) {
throw new RuntimeException();
}
}
}
if ($type === Type::LAST_FISCAL_QUARTER) {
$dt->modify('-3 months');
}
$dtFrom = clone $dt;
$dtTo = clone $dt;
$dtTo = $dtTo->modify('+3 months')->modify('-1 second');
$dtFrom->setTimezone(new DateTimeZone('UTC'));
$dtTo->setTimezone(new DateTimeZone('UTC'));
$where['value'] = [
$dtFrom->format($format),
$dtTo->format($format),
];
break;
default:
$where['type'] = $type;
$where['value'] = $value;
}
return Item::fromRaw($where);
}
}

View File

@@ -0,0 +1,253 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Select\Where\Item\Data;
use InvalidArgumentException;
use RuntimeException;
/**
* A where item.
*
* Immutable.
*/
class Item
{
public const TYPE_AND = Item\Type::AND;
public const TYPE_OR = Item\Type::OR;
private ?string $attribute = null;
private mixed $value = null;
private ?Data $data = null;
/** @var string[] */
private $noAttributeTypeList = [
Item\Type::AND,
Item\Type::OR,
Item\Type::NOT,
Item\Type::SUBQUERY_IN,
Item\Type::SUBQUERY_NOT_IN,
];
/** @var string[] */
private $withNestedItemsTypeList = [
Item\Type::AND,
Item\Type::OR,
];
private function __construct(private string $type)
{}
/**
* @param array<string, mixed> $params
* @internal
*/
public static function fromRaw(array $params): self
{
$type = $params['type'] ?? null;
if (!$type) {
throw new InvalidArgumentException("No 'type' in where item.");
}
$obj = new self($type);
$obj->attribute = $params['attribute'] ?? $params['field'] ?? null;
$obj->value = $params['value'] ?? null;
if ($params['dateTime'] ?? false) {
$obj->data = Data\DateTime
::create()
->withTimeZone($params['timeZone'] ?? null);
} else if ($params['date'] ?? null) {
$obj->data = Data\Date
::create()
->withTimeZone($params['timeZone'] ?? null);
}
unset($params['field']);
unset($params['dateTime']);
unset($params['date']);
unset($params['timeZone']);
foreach (array_keys($params) as $key) {
if (!property_exists($obj, $key)) {
throw new InvalidArgumentException("Unknown parameter '$key'.");
}
}
if (
!$obj->attribute &&
!in_array($obj->type, $obj->noAttributeTypeList)
) {
throw new InvalidArgumentException("No 'attribute' in where item.");
}
if (in_array($obj->type, $obj->withNestedItemsTypeList)) {
$obj->value = $obj->value ?? [];
if (
!is_array($obj->value) ||
count($obj->value) && array_keys($obj->value) !== range(0, count($obj->value) - 1)
) {
throw new InvalidArgumentException("Bad 'value'.");
}
}
return $obj;
}
/**
* @param array<array<int|string, mixed>> $paramList
* {@internal}
*/
public static function fromRawAndGroup(array $paramList): self
{
return self::fromRaw([
'type' => Item\Type::AND,
'value' => $paramList,
]);
}
/**
* @return array{
* type: string,
* value: mixed,
* attribute?: string,
* dateTime?: bool,
* timeZone?: string,
* }
* {@internal}
*/
public function getRaw(): array
{
$type = $this->type;
$raw = [
'type' => $type,
'value' => $this->value,
];
if ($this->attribute) {
$raw['attribute'] = $this->attribute;
}
if ($this->data instanceof Data\DateTime || $this->data instanceof Data\Date) {
if ($this->data instanceof Data\DateTime) {
$raw['dateTime'] = true;
}
$timeZone = $this->data->getTimeZone();
if ($timeZone) {
$raw['timeZone'] = $timeZone;
}
}
return $raw;
}
/**
* Get a type;
*/
public function getType(): string
{
return $this->type;
}
/**
* Get an attribute.
*/
public function getAttribute(): ?string
{
return $this->attribute;
}
/**
* Get a value.
*
* @return mixed
*/
public function getValue()
{
return $this->value;
}
/**
* Get nested where items (for 'and', 'or' types).
*
* @return Item[]
* @throws RuntimeException If a type does not support nested items.
*/
public function getItemList(): array
{
if (!in_array($this->type, $this->withNestedItemsTypeList)) {
throw new RuntimeException("Nested items not supported for '$this->type' type.");
}
$list = [];
foreach ($this->value as $raw) {
$list[] = Item::fromRaw($raw);
}
return $list;
}
/**
* Get a data-object.
*/
public function getData(): ?Data
{
return $this->data;
}
/**
* Create a builder.
*/
public static function createBuilder(): ItemBuilder
{
return new ItemBuilder();
}
/**
* Clone with data.
*
* {@internal}
*/
public function withData(?Data $data): self
{
$obj = clone $this;
$obj->data = $data;
return $obj;
}
}

View File

@@ -0,0 +1,32 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where\Item;
interface Data {}

View File

@@ -0,0 +1,55 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where\Item\Data;
use Espo\Core\Select\Where\Item\Data;
class Date implements Data
{
private ?string $timeZone = null;
public static function create(): self
{
return new self();
}
public function withTimeZone(?string $timeZone): self
{
$obj = clone $this;
$obj->timeZone = $timeZone;
return $obj;
}
public function getTimeZone(): ?string
{
return $this->timeZone;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where\Item\Data;
use Espo\Core\Select\Where\Item\Data;
class DateTime implements Data
{
private ?string $timeZone = null;
public static function create(): self
{
return new self();
}
public function withTimeZone(?string $timeZone): self
{
$obj = clone $this;
$obj->timeZone = $timeZone;
return $obj;
}
public function getTimeZone(): ?string
{
return $this->timeZone;
}
}

View File

@@ -0,0 +1,95 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where\Item;
class Type
{
public const AND = 'and';
public const OR = 'or';
public const NOT = 'not';
public const SUBQUERY_NOT_IN = 'subQueryNotIn';
public const SUBQUERY_IN = 'subQueryIn';
public const EXPRESSION = 'expression';
public const IN = 'in';
public const NOT_IN = 'notIn';
public const EQUALS = 'equals';
public const NOT_EQUALS = 'notEquals';
public const ON = 'on';
public const NOT_ON = 'notOn';
public const LIKE = 'like';
public const NOT_LIKE = 'notLike';
public const STARTS_WITH = 'startsWith';
public const ENDS_WITH = 'endsWith';
public const CONTAINS = 'contains';
public const NOT_CONTAINS = 'notContains';
public const GREATER_THAN = 'greaterThan';
public const LESS_THAN = 'lessThan';
public const GREATER_THAN_OR_EQUALS = 'greaterThanOrEquals';
public const LESS_THAN_OR_EQUALS = 'lessThanOrEquals';
public const AFTER = 'after';
public const BEFORE = 'before';
public const BETWEEN = 'between';
public const EVER = 'ever';
public const ANY = 'any';
public const NONE = 'none';
public const IS_NULL = 'isNull';
public const IS_NOT_NULL = 'isNotNull';
public const IS_TRUE = 'isTrue';
public const IS_FALSE = 'isFalse';
public const TODAY = 'today';
public const PAST = 'past';
public const FUTURE = 'future';
public const LAST_SEVEN_DAYS = 'lastSevenDays';
public const LAST_X_DAYS = 'lastXDays';
public const NEXT_X_DAYS = 'nextXDays';
public const OLDER_THAN_X_DAYS = 'olderThanXDays';
public const AFTER_X_DAYS = 'afterXDays';
public const CURRENT_MONTH = 'currentMonth';
public const NEXT_MONTH = 'nextMonth';
public const LAST_MONTH = 'lastMonth';
public const CURRENT_QUARTER = 'currentQuarter';
public const LAST_QUARTER = 'lastQuarter';
public const CURRENT_YEAR = 'currentYear';
public const LAST_YEAR = 'lastYear';
public const CURRENT_FISCAL_YEAR = 'currentFiscalYear';
public const LAST_FISCAL_YEAR = 'lastFiscalYear';
public const CURRENT_FISCAL_QUARTER = 'currentFiscalQuarter';
public const LAST_FISCAL_QUARTER = 'lastFiscalQuarter';
public const ARRAY_ANY_OF = 'arrayAnyOf';
public const ARRAY_NONE_OF = 'arrayNoneOf';
public const ARRAY_ALL_OF = 'arrayAllOf';
public const ARRAY_IS_EMPTY = 'arrayIsEmpty';
public const ARRAY_IS_NOT_EMPTY = 'arrayIsNotEmpty';
public const IS_LINKED_WITH = 'linkedWith';
public const IS_NOT_LINKED_WITH = 'notLinkedWith';
public const IS_LINKED_WITH_ALL = 'linkedWithAll';
public const IS_LINKED_WITH_ANY = 'isLinked';
public const IS_LINKED_WITH_NONE = 'isNotLinked';
}

View File

@@ -0,0 +1,124 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Select\Where\Item\Data;
/**
* A where-item builder.
*/
class ItemBuilder
{
private ?string $type = null;
private ?string $attribute = null;
/** @var mixed */
private $value = null;
private ?Data $data = null;
public static function create(): self
{
return new self();
}
/**
* Set a type.
*
* @param (Item\Type::*)|string $type
* @return $this
* @noinspection PhpDocSignatureInspection
*/
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
/**
* Set a value.
*
* @param mixed $value
*/
public function setValue($value): self
{
$this->value = $value;
return $this;
}
/**
* Set an attribute.
*/
public function setAttribute(?string $attribute): self
{
$this->attribute = $attribute;
return $this;
}
/**
* Set data.
*/
public function setData(?Data $data): self
{
$this->data = $data;
return $this;
}
/**
* Set nested where item list.
*
* @param Item[] $itemList
* @return self
*/
public function setItemList(array $itemList): self
{
$this->value = array_map(
function (Item $item): array {
return $item->getRaw();
},
$itemList
);
return $this;
}
public function build(): Item
{
return Item
::fromRaw([
'type' => $this->type,
'attribute' => $this->attribute,
'value' => $this->value,
])
->withData($this->data);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Exceptions\BadRequest;
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* Converts a where item to a where clause item (for ORM).
*/
interface ItemConverter
{
/**
* @throws BadRequest
*/
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem;
}

View File

@@ -0,0 +1,118 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Binding\Binder;
use Espo\Core\Binding\BindingContainer;
use Espo\Core\Binding\BindingData;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use RuntimeException;
class ItemConverterFactory
{
public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata)
{}
public function hasForType(string $type): bool
{
return (bool) $this->getClassNameForType($type);
}
public function createForType(string $type, string $entityType, User $user): ItemConverter
{
$className = $this->getClassNameForType($type);
if (!$className) {
throw new RuntimeException("Where item converter class name is not defined.");
}
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user);
$binder
->for($className)
->bindValue('$entityType', $entityType);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
/**
* @return ?class-string<ItemConverter>
*/
protected function getClassNameForType(string $type): ?string
{
return $this->metadata->get(['app', 'select', 'whereItemConverterClassNameMap', $type]);
}
public function has(string $entityType, string $attribute, string $type): bool
{
return (bool) $this->getClassName($entityType, $attribute, $type);
}
public function create(string $entityType, string $attribute, string $type, User $user): ItemConverter
{
$className = $this->getClassName($entityType, $attribute, $type);
if (!$className) {
throw new RuntimeException("Where item converter class name is not defined.");
}
$bindingData = new BindingData();
$binder = new Binder($bindingData);
$binder
->bindInstance(User::class, $user);
$binder
->for($className)
->bindValue('$entityType', $entityType);
$bindingContainer = new BindingContainer($bindingData);
return $this->injectableFactory->createWithBinding($className, $bindingContainer);
}
/**
* @return ?class-string<ItemConverter>
*/
protected function getClassName(string $entityType, string $attribute, string $type): ?string
{
return $this->metadata
->get(['selectDefs', $entityType, 'whereItemConverterClassNameMap', $attribute . '_' . $type]);
}
}

View File

@@ -0,0 +1,116 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where\ItemConverters;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Select\Where\Item;
use Espo\Core\Select\Where\ItemConverter;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* @noinspection PhpUnused
*/
class InCategory implements ItemConverter
{
public function __construct(
private string $entityType,
private Defs $ormDefs
) {}
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem
{
$link = $item->getAttribute();
$value = $item->getValue();
if (!$link) {
throw new BadRequest("No attribute.");
}
if ($value === null) {
return WhereClause::create();
}
if (!is_array($value)) {
$value = [$value];
}
foreach ($value as $it) {
if (!is_string($it) && !is_int($it)) {
throw new BadRequest("Bad where item. Bad array item.");
}
}
$entityDefs = $this->ormDefs->getEntity($this->entityType);
if (!$entityDefs->hasRelation($link)) {
throw new BadRequest("Not existing '$link' in where item.");
}
$defs = $entityDefs->getRelation($link);
$path = lcfirst($defs->getForeignEntityType()) . 'Path';
if ($defs->getType() === Entity::MANY_MANY) {
$middle = ucfirst($defs->getRelationshipName());
return Cond::in(
Expr::column('id'),
QueryBuilder::create()
->from($middle, 'm')
->select($defs->getMidKey())
->join(
ucfirst($path),
$path,
["$path.descendorId:" => "m.{$defs->getForeignMidKey()}"]
)
->where(["$path.ascendorId" => $value])
->build()
);
}
if ($defs->getType() === Entity::BELONGS_TO) {
$queryBuilder->join(
ucfirst($path),
$path,
["$path.descendorId:" => $defs->getKey()]
);
return WhereClause::fromRaw(["$path.ascendorId" => $value]);
}
throw new BadRequest("Not supported link '$link' in where item.");
}
}

View File

@@ -0,0 +1,107 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where\ItemConverters;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Select\Where\Item;
use Espo\Core\Select\Where\ItemConverter;
use Espo\Entities\Team;
use Espo\Entities\User;
use Espo\ORM\Defs;
use Espo\ORM\Entity;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\WhereClause;
use Espo\ORM\Query\Part\WhereItem as WhereClauseItem;
use Espo\ORM\Query\SelectBuilder;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
/**
* @noinspection PhpUnused
*/
class IsUserFromTeams implements ItemConverter
{
public function __construct(
private string $entityType,
private Defs $ormDefs,
) {}
public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem
{
$link = $item->getAttribute();
$value = $item->getValue();
if (!$link) {
throw new BadRequest("No attribute.");
}
if ($value === null) {
return WhereClause::create();
}
if (!is_array($value)) {
$value = [$value];
}
foreach ($value as $it) {
if (!is_string($it) && !is_int($it)) {
throw new BadRequest("Bad where item. Bad array item.");
}
}
$entityDefs = $this->ormDefs->getEntity($this->entityType);
if (!$entityDefs->hasRelation($link)) {
throw new BadRequest("Not existing '$link' in where item.");
}
$defs = $entityDefs->getRelation($link);
$relationType = $defs->getType();
$entityType = $defs->getForeignEntityType();
if ($entityType !== User::ENTITY_TYPE) {
throw new BadRequest("Not supported link '$link' in where item.");
}
if ($relationType === Entity::BELONGS_TO) {
return Condition::in(
Expression::column($defs->getKey()),
SelectBuilder::create()
->from(Team::RELATIONSHIP_TEAM_USER, 'sq')
->select('userId')
->where(['teamId' => $value])
->build()
);
}
throw new BadRequest("Not supported link '$link' in where item.");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use InvalidArgumentException;
/**
* Where parameters.
*
* Immutable.
*/
class Params
{
private bool $applyPermissionCheck = false;
private bool $forbidComplexExpressions = false;
private function __construct()
{}
/**
* @param array{
* applyPermissionCheck?: bool,
* forbidComplexExpressions?: bool,
* } $params
*/
public static function fromAssoc(array $params): self
{
$object = new self();
$object->applyPermissionCheck = $params['applyPermissionCheck'] ?? false;
$object->forbidComplexExpressions = $params['forbidComplexExpressions'] ?? false;
foreach ($params as $key => $value) {
if (!property_exists($object, $key)) {
throw new InvalidArgumentException("Unknown parameter '$key'.");
}
}
return $object;
}
/**
* Apply permission check.
*/
public function applyPermissionCheck(): bool
{
return $this->applyPermissionCheck;
}
/**
* Forbid complex expressions.
*/
public function forbidComplexExpressions(): bool
{
return $this->forbidComplexExpressions;
}
}

View File

@@ -0,0 +1,233 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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\Select\Where;
use Espo\Core\Select\Where\Item\Type;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\EntityManager;
use Espo\ORM\Entity;
use Espo\ORM\BaseEntity;
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
use Espo\ORM\QueryComposer\Util as QueryComposerUtil;
use Espo\ORM\Type\RelationType;
use RuntimeException;
/**
* Scans where items.
*/
class Scanner
{
/** @var array<string, Entity> */
private array $seedHash = [];
/** @var string[] */
private $nestingTypeList = [
Type::OR,
Type::AND,
];
/** @var string[] */
private array $subQueryTypeList = [
Type::SUBQUERY_IN,
Type::SUBQUERY_NOT_IN,
Type::NOT,
];
public function __construct(private EntityManager $entityManager)
{}
/**
* Check whether at least one has-many link appears in the where-clause.
*
* @since 9.0.0
*/
public function hasRelatedMany(string $entityType, Item $item): bool
{
$type = $item->getType();
$attribute = $item->getAttribute();
if (in_array($type, $this->subQueryTypeList)) {
return false;
}
if (in_array($type, $this->nestingTypeList)) {
foreach ($item->getItemList() as $subItem) {
if ($this->hasRelatedMany($entityType, $subItem)) {
return true;
}
}
return false;
}
if (!$attribute) {
return false;
}
$seed = $this->getSeed($entityType);
foreach (QueryComposerUtil::getAllAttributesFromComplexExpression($attribute) as $expr) {
if (!str_contains($expr, '.')) {
continue;
}
[$link,] = explode('.', $expr);
if (!$seed->hasRelation($link)) {
continue;
}
$isMany = in_array($seed->getRelationType($link), [
RelationType::HAS_MANY,
RelationType::MANY_MANY,
RelationType::HAS_CHILDREN,
]);
if ($isMany) {
return true;
}
}
return false;
}
/**
* Apply needed joins to a query builder.
*/
public function apply(QueryBuilder $queryBuilder, Item $item): void
{
$entityType = $queryBuilder->build()->getFrom();
if (!$entityType) {
throw new RuntimeException("No entity type.");
}
$this->applyLeftJoinsFromItem($queryBuilder, $item, $entityType);
}
private function applyLeftJoinsFromItem(QueryBuilder $queryBuilder, Item $item, string $entityType): void
{
$type = $item->getType();
$value = $item->getValue();
$attribute = $item->getAttribute();
if (in_array($type, $this->subQueryTypeList)) {
return;
}
if (in_array($type, $this->nestingTypeList)) {
if (!is_array($value)) {
return;
}
foreach ($value as $subItem) {
$this->applyLeftJoinsFromItem($queryBuilder, Item::fromRaw($subItem), $entityType);
}
return;
}
if (!$attribute) {
return;
}
$this->applyLeftJoinsFromAttribute($queryBuilder, $attribute, $entityType);
}
private function applyLeftJoinsFromAttribute(
QueryBuilder $queryBuilder,
string $attribute,
string $entityType
): void {
if (str_contains($attribute, ':')) {
$argumentList = QueryComposerUtil::getAllAttributesFromComplexExpression($attribute);
foreach ($argumentList as $argument) {
$this->applyLeftJoinsFromAttribute($queryBuilder, $argument, $entityType);
}
return;
}
$seed = $this->getSeed($entityType);
if (str_contains($attribute, '.')) {
[$link,] = explode('.', $attribute);
if ($seed->hasRelation($link)) {
$queryBuilder->leftJoin($link);
}
return;
}
$attributeType = $seed->getAttributeType($attribute);
if ($attributeType === Entity::FOREIGN) {
$relation = $this->getAttributeParam($seed, $attribute, AttributeParam::RELATION);
if ($relation) {
$queryBuilder->leftJoin($relation);
}
}
}
private function getSeed(string $entityType): Entity
{
if (!isset($this->seedHash[$entityType])) {
$this->seedHash[$entityType] = $this->entityManager->getNewEntity($entityType);
}
return $this->seedHash[$entityType];
}
/**
* @return mixed
* @noinspection PhpSameParameterValueInspection
*/
private function getAttributeParam(Entity $entity, string $attribute, string $param)
{
if ($entity instanceof BaseEntity) {
return $entity->getAttributeParam($attribute, $param);
}
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType());
if (!$entityDefs->hasAttribute($attribute)) {
return null;
}
return $entityDefs->getAttribute($attribute)->getParam($param);
}
}