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,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);
}
}