Files
espocrm/application/Espo/ORM/QueryComposer/BaseQueryComposer.php
bsiggel 127fa6503b chore: Update copyright year from 2025 to 2026 across core files
- Updated copyright headers in 3,055 core application files
- Changed 'Copyright (C) 2014-2025' to 'Copyright (C) 2014-2026'
- Added 123 new files from EspoCRM core updates
- Removed 4 deprecated files
- Total changes: 61,637 insertions, 54,283 deletions

This is a routine maintenance update for the new year 2026.
2026-02-07 16:05:21 +01:00

3862 lines
114 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 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\ORM\QueryComposer;
use Espo\Core\ORM\Type\FieldType;
use Espo\ORM\Defs\Params\AttributeParam;
use Espo\ORM\Defs\Params\EntityParam;
use Espo\ORM\Defs\Params\IndexParam;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Entity;
use Espo\ORM\EntityFactory;
use Espo\ORM\BaseEntity;
use Espo\ORM\EventDispatcher;
use Espo\ORM\Metadata;
use Espo\ORM\Mapper\Helper;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\Join\JoinType;
use Espo\ORM\Query\Query;
use Espo\ORM\Query\SelectingQuery;
use Espo\ORM\Query\Select;
use Espo\ORM\Query\Update;
use Espo\ORM\Query\Insert;
use Espo\ORM\Query\Delete;
use Espo\ORM\Query\Union;
use Espo\ORM\QueryComposer\Part\FunctionConverterFactory;
use Espo\ORM\Type\AttributeType;
use PDO;
use RuntimeException;
use LogicException;
use const STR_PAD_LEFT;
/**
* Composes SQL queries.
*
* @todo Break into sub-classes. Put sub-classes into `\Part` namespace.
* @todo Use entityDefs. Don't use methods of BaseEntity.
*/
abstract class BaseQueryComposer implements QueryComposer
{
/**
* @var string[]
* @todo Remove.
*/
protected const PARAM_LIST = [
'select',
'whereClause',
'offset',
'limit',
'order',
'orderBy',
'customWhere',
'customJoin',
'joins',
'leftJoins',
'distinct',
'joinConditions',
'groupBy',
'havingClause',
'customHaving',
'skipTextColumns',
'maxTextColumnsLength',
'useIndex',
'withDeleted',
'set',
'from',
'fromAlias',
'fromQuery',
'forUpdate',
'forShare',
'withRecursive',
];
/** @var string[] */
protected const SQL_OPERATORS = [
'OR',
'AND',
];
protected const EXISTS_OPERATOR = 'EXISTS';
/** @var string[] */
private array $comparisonOperators = [
'!=s',
'=s',
'!=',
'!*',
'*',
'>=',
'<=',
'>',
'<',
'=',
'>=any',
'<=any',
'>any',
'<any',
'!=any',
'=any',
'>=all',
'<=all',
'>all',
'<all',
'!=all',
'=all',
];
/** @var array<string, string> */
protected array $comparisonOperatorMap = [
'!=s' => 'NOT IN',
'=s' => 'IN',
'!=' => '<>',
'!*' => 'NOT LIKE',
'*' => 'LIKE',
'>=any' => '>= ANY',
'<=any' => '<= ANY',
'>any' => '> ANY',
'<any' => '< ANY',
'!=any' => '<> ANY',
'=any' => '= ANY',
'>=all' => '>= ALL',
'<=all' => '<= ALL',
'>all' => '> ALL',
'<all' => '< ALL',
'!=all' => '<> ALL',
'=all' => '= ALL',
];
/** @var array<string, string> */
protected array $comparisonFunctionOperatorMap = [
'LIKE' => 'LIKE',
'NOT_LIKE' => 'NOT LIKE',
'EQUAL' => '=',
'NOT_EQUAL' => '<>',
'GREATER_THAN' => '>',
'LESS_THAN' => '<',
'GREATER_THAN_OR_EQUAL' => '>=',
'LESS_THAN_OR_EQUAL' => '<=',
'IS_NULL' => 'IS NULL',
'IS_NOT_NULL' => 'IS NOT NULL',
'IN' => 'IN',
'NOT_IN' => 'NOT IN',
];
/** @var array<string, string> */
protected array $mathFunctionOperatorMap = [
'ADD' => '+',
'SUB' => '-',
'MUL' => '*',
'DIV' => '/',
'MOD' => '%',
];
protected const SELECT_METHOD = 'SELECT';
protected const DELETE_METHOD = 'DELETE';
protected const UPDATE_METHOD = 'UPDATE';
protected const INSERT_METHOD = 'INSERT';
protected string $identifierQuoteCharacter = '`';
protected int $aliasMaxLength = 256;
protected bool $indexHints = true;
protected bool $skipForeignIfForUpdate = false;
protected EntityFactory $entityFactory;
protected PDO $pdo;
protected Metadata $metadata;
protected ?FunctionConverterFactory $functionConverterFactory;
protected Helper $helper;
/** @var array<string, string> */
protected array $attributeDbMapCache = [];
/** @var array<string, array<string, string>> */
protected $aliasesCache = [];
/** @var array<string, Entity> */
protected $seedCache = [];
public function __construct(
PDO $pdo,
EntityFactory $entityFactory,
Metadata $metadata,
?FunctionConverterFactory $functionConverterFactory = null,
?EventDispatcher $eventDispatcher = null
) {
$this->entityFactory = $entityFactory;
$this->pdo = $pdo;
$this->metadata = $metadata;
$this->functionConverterFactory = $functionConverterFactory;
$this->helper = new Helper($metadata);
$eventDispatcher?->subscribeToMetadataUpdate(fn () => $this->seedCache = []);
}
protected function quoteIdentifier(string $string): string
{
return $this->identifierQuoteCharacter . $string . $this->identifierQuoteCharacter;
}
protected function quoteColumn(string $column): string
{
return $column;
}
protected function getSeed(?string $entityType): Entity
{
if (!$entityType) {
return new BaseEntity('_Stub', []);
}
if (empty($this->seedCache[$entityType])) {
$this->seedCache[$entityType] = $this->entityFactory->create($entityType);
}
return $this->seedCache[$entityType];
}
/**
* @deprecated As of v7.2. Use the wrapper or methods directly.
*/
public function compose(Query $query): string
{
$wrapper = new QueryComposerWrapper($this);
return $wrapper->compose($query);
}
public function composeCreateSavepoint(string $savepointName): string
{
/** @noinspection PhpDeprecationInspection */
return 'SAVEPOINT ' . $this->sanitize($savepointName);
}
public function composeReleaseSavepoint(string $savepointName): string
{
/** @noinspection PhpDeprecationInspection */
return 'RELEASE SAVEPOINT ' . $this->sanitize($savepointName);
}
public function composeRollbackToSavepoint(string $savepointName): string
{
/** @noinspection PhpDeprecationInspection */
return 'ROLLBACK TO SAVEPOINT ' . $this->sanitize($savepointName);
}
protected function composeSelecting(SelectingQuery $query): string
{
if ($query instanceof Select) {
return $this->composeSelect($query);
}
if ($query instanceof Union) {
return $this->composeUnion($query);
}
throw new RuntimeException("Unknown query type.");
}
public function composeSelect(Select $query): string
{
$params = $query->getRaw();
return $this->createSelectQueryInternal($params);
}
public function composeUpdate(Update $query): string
{
$params = $query->getRaw();
return $this->createUpdateQuery($params);
}
public function composeDelete(Delete $query): string
{
$params = $query->getRaw();
return $this->createDeleteQuery($params);
}
public function composeInsert(Insert $query): string
{
$params = $query->getRaw();
return $this->createInsertQuery($params);
}
public function composeUnion(Union $query): string
{
$params = $query->getRaw();
return $this->createUnionQuery($params);
}
/**
* @param array<string, mixed>|null $params
*/
protected function createDeleteQuery(?array $params = null): string
{
$params = $this->normalizeParams(self::DELETE_METHOD, $params);
$entityType = $params['from'];
$alias = $params['fromAlias'] ?? null;
$entity = $this->getSeed($entityType);
$wherePart = $this->getWherePart($entity, $params['whereClause'], 'AND', $params);
$orderPart = $this->getOrderPart($entity, $params['orderBy'], $params['order'], $params);
$joinsPart = $this->getJoinsPart($entity, $params);
$aliasPart = null;
if ($alias) {
/** @noinspection PhpDeprecationInspection */
$aliasPart = $this->sanitize($alias);
}
return $this->composeDeleteQuery(
$this->toDb($entityType),
$aliasPart,
$wherePart,
$joinsPart,
$orderPart,
$params['limit']
);
}
/**
* @param array<string, mixed>|null $params
*/
protected function createUpdateQuery(?array $params = null): string
{
$params = $this->normalizeParams(self::UPDATE_METHOD, $params);
$entityType = $params['from'];
$values = $params['set'];
$entity = $this->getSeed($entityType);
$wherePart = $this->getWherePart($entity, $params['whereClause'], 'AND', $params);
$orderPart = $this->getOrderPart($entity, $params['orderBy'], $params['order'], $params);
$joinsPart = $this->getJoinsPart($entity, $params);
$setPart = $this->getSetPart($entity, $values, $params);
return $this->composeUpdateQuery(
$this->toDb($entityType),
$setPart,
$wherePart,
$joinsPart,
$orderPart,
$params['limit']
);
}
/**
* @param array<string, mixed>|null $params
*/
protected function createInsertQuery(?array $params): string
{
$params = $this->normalizeInsertParams($params ?? []);
$entityType = $params['into'];
$columns = $params['columns'];
$updateSet = $params['updateSet'];
$columnsPart = $this->getInsertColumnsPart($columns);
$valuesPart = $this->getInsertValuesPart($entityType, $params);
$updatePart = null;
if ($updateSet) {
$updatePart = $this->getInsertUpdatePart($updateSet);
}
return $this->composeInsertQuery($this->toDb($entityType), $columnsPart, $valuesPart, $updatePart);
}
/**
* @param array<string, mixed> $params
* @noinspection PhpUnusedParameterInspection
*/
protected function getInsertValuesPart(string $entityType, array $params): string
{
$isMass = $params['isMass'];
$isBySelect = $params['isBySelect'];
$columns = $params['columns'];
$values = $params['values'];
$valuesQuery = $params['valuesQuery'] ?? null;
if ($isBySelect) {
return $this->composeSelecting($valuesQuery);
}
if ($isMass) {
$list = [];
foreach ($values as $item) {
$list[] = '(' . $this->getInsertValuesItemPart($columns, $item) . ')';
}
return 'VALUES ' . implode(', ', $list);
}
return 'VALUES (' . $this->getInsertValuesItemPart($columns, $values) . ')';
}
/**
* @param array<string, mixed> $params
*/
protected function createUnionQuery(array $params): string
{
$selectQueryList = $params['queries'] ?? [];
$isAll = $params['all'] ?? false;
$limit = $params['limit'] ?? null;
$offset = $params['offset'] ?? null;
$orderBy = $params['orderBy'] ?? [];
$subSqlList = [];
foreach ($selectQueryList as $select) {
$rawSelectParams = $select->getRaw();
$rawSelectParams['strictSelect'] = true;
$select = Select::fromRaw($rawSelectParams);
$subSqlList[] = '(' . $this->composeSelect($select) . ')';
}
$joiner = 'UNION';
if ($isAll) {
$joiner .= ' ALL';
}
$joiner = ' ' . $joiner . ' ';
$sql = implode($joiner, $subSqlList);
if (!empty($orderBy)) {
$sql .= " ORDER BY " . $this->getUnionOrderPart($orderBy);
}
if ($limit !== null || $offset !== null) {
$sql = $this->limit($sql, $offset, $limit);
}
return $sql;
}
/**
* @param array<string|mixed[]> $orderBy
*/
protected function getUnionOrderPart(array $orderBy): string
{
$orderByParts = [];
foreach ($orderBy as $item) {
$direction = $item[1] ?? 'ASC';
if (is_bool($direction)) {
$direction = $direction ? 'DESC' : 'ASC';
}
if (is_int($item[0])) {
$by = (string) $item[0];
} else {
/** @noinspection PhpDeprecationInspection */
$by = $this->quoteIdentifier(
$this->sanitizeSelectAlias($item[0])
);
}
$orderByParts[] = $by . ' ' . $direction;
}
return implode(', ', $orderByParts);
}
/**
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
protected function normalizeInsertParams(array $params): array
{
$columns = $params['columns'] ?? null;
if (empty($columns) || !is_array($columns)) {
throw new RuntimeException("ORM Query: 'columns' is empty for INSERT.");
}
$values = $params['values'] = $params['values'] ?? null;
$valuesQuery = $params['valuesQuery'] = $params['valuesQuery'] ?? null;
$isBySelect = false;
if ($valuesQuery) {
$isBySelect = true;
}
if (!$isBySelect) {
if (empty($values) || !is_array($values)) {
throw new RuntimeException("ORM Query: 'values' is empty for INSERT.");
}
}
$params['isBySelect'] = $isBySelect;
$isMass = !$isBySelect && array_keys($values)[0] === 0;
$params['isMass'] = $isMass;
if (!$isBySelect) {
if (!$isMass) {
foreach ($columns as $item) {
if (!array_key_exists($item, $values)) {
throw new RuntimeException(
"ORM Query: 'values' should contain all items listed in 'columns'."
);
}
}
} else {
foreach ($values as $valuesItem) {
foreach ($columns as $item) {
if (!array_key_exists($item, $valuesItem)) {
throw new RuntimeException(
"ORM Query: 'values' should contain all items listed in 'columns'."
);
}
}
}
}
}
$updateSet = $params['updateSet'] = $params['updateSet'] ?? null;
if ($updateSet && !is_array($updateSet)) {
throw new RuntimeException("ORM Query: Bad 'updateSet' param.");
}
return $params;
}
/**
* @param array<string, mixed>|null $params
* @return array<string, mixed>
*/
protected function normalizeParams(string $method, ?array $params): array
{
$params = $params ?? [];
foreach (self::PARAM_LIST as $k) {
$params[$k] = array_key_exists($k, $params) ? $params[$k] : null;
}
$params['distinct'] = $params['distinct'] ?? false;
$params['skipTextColumns'] = $params['skipTextColumns'] ?? false;
$params['joins'] = $params['joins'] ?? [];
$params['leftJoins'] = $params['leftJoins'] ?? [];
if ($method !== self::SELECT_METHOD) {
if (isset($params['offset'])) {
throw new RuntimeException("ORM Query: Param 'offset' is not allowed for '$method'.");
}
}
if ($method !== self::UPDATE_METHOD) {
if (isset($params['set'])) {
throw new RuntimeException("ORM Query: Param 'set' is not allowed for '$method'.");
}
}
if (isset($params['set']) && !is_array($params['set'])) {
throw new RuntimeException("ORM Query: Param 'set' should be an array.");
}
return $params;
}
/**
* @param array<string, mixed>|null $params
*/
protected function createSelectQueryInternal(?array $params = null): string
{
$params = $this->normalizeParams(self::SELECT_METHOD, $params);
$entityType = $params['from'] ?? null;
$fromQuery = $params['fromQuery'] ?? null;
$fromAlias = $params['fromAlias'] ?? null;
$whereClause = $params['whereClause'] ?? [];
$havingClause = $params['havingClause'] ?? [];
if ($entityType === null && !$fromQuery) {
return $this->createSelectQueryNoFrom($params);
}
$entity = $this->getSeed($entityType);
if (!$params['withDeleted'] && $entity->hasAttribute(Attribute::DELETED)) {
$whereClause = $whereClause + [Attribute::DELETED => false];
}
$wherePart = $this->getWherePart($entity, $whereClause, 'AND', $params);
$havingPart = $havingClause ? $this->getWherePart($entity, $havingClause, 'AND', $params) : null;
$orderPart = $this->getOrderPart($entity, $params['orderBy'], $params['order'], $params);
$selectPart = $this->getSelectPart($entity, $params);
$selectPart .= $this->getAdditionalSelect($entity, $params) ?? '';
$tailPart = $this->getSelectTailPart($params);
$joinsPart = $this->getJoinsPart($entity, $params, true);
$groupByPart = $this->getGroupByPart($entity, $params);
$withRecursivePart = $this->getWithRecursivePart($params);
// @todo remove 'customWhere' support
if (!empty($params['customWhere'])) {
if ($wherePart) {
$wherePart .= ' ';
}
$wherePart .= $params['customWhere'];
}
// @todo remove 'customHaving' support
if (!empty($params['customHaving'])) {
if (!empty($havingPart)) {
$havingPart .= ' ';
}
$havingPart .= $params['customHaving'];
}
$indexKeyList = $entityType ?
$this->getIndexKeyList($entityType, $params) : null;
/** @noinspection PhpDeprecationInspection */
$fromAlias = $fromAlias ?
$this->sanitize($fromAlias) : null;
$fromPart = $fromQuery ?
'(' . $this->composeSelecting($fromQuery) . ')' :
(
$entityType ?
$this->quoteIdentifier($this->toDb($entityType)) : null
);
/** @var string $selectPart */
/** @var string $fromAlias */
return $this->composeSelectQuery(
from: $fromPart,
select: $selectPart,
alias: $fromAlias,
joins: $joinsPart,
where: $wherePart,
order: $orderPart,
offset: $params['offset'],
limit: $params['limit'],
distinct: $params['distinct'],
groupBy: $groupByPart,
having: $havingPart,
indexKeyList: $indexKeyList,
tailPart: $tailPart,
withRecursivePart: $withRecursivePart,
);
}
/**
* @param array<string, mixed> $params
*/
protected function createSelectQueryNoFrom(array $params): string
{
$selectPart = $this->getSelectPart(null, $params);
return $this->composeSelectQuery(null, $selectPart);
}
/**
* @param array<string, mixed> $params
* @return string[]|null
*/
protected function getIndexKeyList(string $entityType, array $params): ?array
{
$indexKeyList = [];
$indexList = $params['useIndex'] ?? null;
if (empty($indexList)) {
return null;
}
if (is_string($indexList)) {
$indexList = [$indexList];
}
foreach ($indexList as $indexName) {
$indexKey = $this->metadata->get($entityType, [EntityParam::INDEXES, $indexName, IndexParam::KEY]);
if ($indexKey) {
$indexKeyList[] = $indexKey;
}
}
return $indexKeyList;
}
/**
* @param array<string, mixed> $params
*/
private function skipForeign(array $params): bool
{
return $this->skipForeignIfForUpdate && ($params['forUpdate'] ?? false);
}
/**
* @param array<string, mixed> $params
*/
protected function getJoinsPart(Entity $entity, array $params, bool $includeBelongsTo = false): string
{
if ($includeBelongsTo && $this->skipForeign($params)) {
$includeBelongsTo = false;
}
$joinsPart = '';
if ($includeBelongsTo) {
$joinsPart = $this->getBelongsToJoinsPart(
entity: $entity,
select: $params['select'],
explicitJoins: array_merge($params['joins'], $params['leftJoins']),
params: $params,
);
}
if (!empty($params['joins']) && is_array($params['joins'])) {
// @todo array unique
$joinsItemPart = $this->getJoinsTypePart(
entity: $entity,
joins: $params['joins'],
params: $params,
joinConditions: $params['joinConditions'],
);
if (!empty($joinsItemPart)) {
if (!empty($joinsPart)) {
$joinsPart .= ' ';
}
$joinsPart .= $joinsItemPart;
}
}
// For bc.
if (!empty($params['leftJoins']) && is_array($params['leftJoins'])) {
$joinsItemPart = $this->getJoinsTypePart(
entity: $entity,
joins: $params['leftJoins'],
params: $params,
joinConditions: $params['joinConditions'],
isLeft: true,
);
if (!empty($joinsItemPart)) {
if (!empty($joinsPart)) {
$joinsPart .= ' ';
}
$joinsPart .= $joinsItemPart;
}
}
// @todo Remove custom join.
if (!empty($params['customJoin'])) {
if (!empty($joinsPart)) {
$joinsPart .= ' ';
}
$joinsPart .= $params['customJoin'];
}
return $joinsPart;
}
/**
* @param array<string, mixed> $params
*/
protected function getGroupByPart(Entity $entity, array $params): ?string
{
if (empty($params['groupBy'])) {
return null;
}
$list = [];
foreach ($params['groupBy'] as $field) {
$list[] = $this->convertComplexExpression($entity, $field, false, $params);
}
return implode(', ', $list);
}
/**
* @param array<string, mixed> $params
*/
protected function getAdditionalSelect(Entity $entity, array $params): ?string
{
if (!empty($params['strictSelect'])) {
return null;
}
$selectPart = '';
if (!empty($params['extraAdditionalSelect'])) {
$extraSelect = [];
foreach ($params['extraAdditionalSelect'] as $item) {
if (!in_array($item, $params['select'] ?? [])) {
$extraSelect[] = $item;
}
}
if (count($extraSelect)) {
$newParams = ['select' => $extraSelect];
$extraSelectPart = $this->getSelectPart($entity, $newParams);
if ($extraSelectPart) {
$selectPart .= ', ' . $extraSelectPart;
}
}
}
/*if (!empty($params['additionalSelectColumns']) && is_array($params['additionalSelectColumns'])) {
foreach ($params['additionalSelectColumns'] as $column => $field) {
$itemAlias = $this->sanitizeSelectAlias($field);
$selectPart .= ", " . $column . " AS " . $this->quoteIdentifier($itemAlias);
}
}*/
if ($selectPart === '') {
return null;
}
return $selectPart;
}
/**
* @param string[] $argumentPartList
* @param array<string, mixed> $params
*/
protected function getFunctionPart(
string $function,
string $part,
array $params,
string $entityType,
bool $distinct,
array $argumentPartList = []
): string {
$isBuiltIn = in_array($function, Functions::FUNCTION_LIST);
if (
!$isBuiltIn &&
(
!$this->functionConverterFactory ||
!$this->functionConverterFactory->isCreatable($function)
)
) {
throw new RuntimeException("ORM Query: Not allowed function '$function'.");
}
if (in_array($function, ['MATCH_BOOLEAN', 'MATCH_NATURAL_LANGUAGE'])) {
if (count($argumentPartList) < 2) {
throw new RuntimeException("Not enough arguments for MATCH function.");
}
$queryPart = end($argumentPartList);
$columnsPart = implode(', ', array_splice($argumentPartList, 0, -1));
$modePart = $function === 'MATCH_BOOLEAN' ?
'IN BOOLEAN MODE' : 'IN NATURAL LANGUAGE MODE';
return "MATCH ($columnsPart) AGAINST ($queryPart $modePart)";
}
if (str_starts_with($function, 'YEAR_') && $function !== 'YEAR_NUMBER') {
$fiscalShift = substr($function, 5);
if (is_numeric($fiscalShift)) {
$fiscalShift = (int) $fiscalShift;
$fiscalFirstMonth = $fiscalShift + 1;
return
"CASE WHEN MONTH($part) >= $fiscalFirstMonth THEN ".
"YEAR($part) ".
"ELSE YEAR($part) - 1 END";
}
}
if (str_starts_with($function, 'QUARTER_') && $function !== 'QUARTER_NUMBER') {
$fiscalShift = substr($function, 8);
if (is_numeric($fiscalShift)) {
$fiscalShift = (int) $fiscalShift;
$fiscalFirstMonth = $fiscalShift + 1;
$fiscalDistractedMonth = $fiscalFirstMonth < 4 ?
12 - $fiscalFirstMonth :
12 - $fiscalFirstMonth + 1;
return
"CASE WHEN MONTH($part) >= $fiscalFirstMonth THEN ".
"CONCAT(YEAR($part), '_', FLOOR((MONTH($part) - $fiscalFirstMonth) / 3) + 1) ".
"ELSE CONCAT(YEAR($part) - 1, '_', CEIL((MONTH($part) + $fiscalDistractedMonth) / 3)) END";
}
}
if ($function === 'TZ') {
return $this->getFunctionPartTZ($argumentPartList);
}
if (in_array($function, Functions::COMPARISON_FUNCTION_LIST)) {
if (count($argumentPartList) < 2) {
throw new RuntimeException("Not enough arguments for function '$function'.");
}
$operator = $this->comparisonFunctionOperatorMap[$function];
return $argumentPartList[0] . ' ' . $operator . ' ' . $argumentPartList[1];
}
if (in_array($function, Functions::MATH_OPERATION_FUNCTION_LIST)) {
if (count($argumentPartList) < 2) {
throw new RuntimeException("ORM Query: Not enough arguments for function '$function'.");
}
$operator = $this->mathFunctionOperatorMap[$function];
return '(' . implode(' ' . $operator . ' ', $argumentPartList) . ')';
}
if (in_array($function, ['IN', 'NOT_IN'])) {
$operator = $this->comparisonFunctionOperatorMap[$function];
if (count($argumentPartList) < 2) {
throw new RuntimeException("ORM Query: Not enough arguments for function '$function'.");
}
$operatorArgumentList = $argumentPartList;
array_shift($operatorArgumentList);
return $argumentPartList[0] . ' ' . $operator . ' (' . implode(', ', $operatorArgumentList) . ')';
}
if (in_array($function, ['IS_NULL', 'IS_NOT_NULL'])) {
$operator = $this->comparisonFunctionOperatorMap[$function];
return $part . ' ' . $operator;
}
if (in_array($function, ['OR', 'AND'])) {
return '(' . implode(' ' . $function . ' ', $argumentPartList) . ')';
}
if (!$isBuiltIn && $this->functionConverterFactory) {
return $this->getFunctionPartFromFactory($function, $argumentPartList);
}
switch ($function) {
case 'SWITCH':
if (count($argumentPartList) < 2) {
throw new RuntimeException("Not enough arguments for SWITCH function.");
}
$part = "CASE";
for ($i = 0; $i < floor(count($argumentPartList) / 2); $i++) {
$whenPart = $argumentPartList[$i * 2];
$thenPart = $argumentPartList[$i * 2 + 1];
$part .= " WHEN $whenPart THEN $thenPart";
}
if (count($argumentPartList) % 2) {
$part .= " ELSE " . end($argumentPartList);
}
$part .= " END";
return $part;
case 'MAP':
if (count($argumentPartList) < 3) {
throw new RuntimeException("Not enough arguments for MAP function.");
}
$part = "CASE " . $argumentPartList[0];
array_shift($argumentPartList);
for ($i = 0; $i < floor(count($argumentPartList) / 2); $i++) {
$whenPart = $argumentPartList[$i * 2];
$thenPart = $argumentPartList[$i * 2 + 1];
$part .= " WHEN $whenPart THEN $thenPart";
}
if (count($argumentPartList) % 2) {
$part .= " ELSE " . end($argumentPartList);
}
$part .= " END";
return $part;
case 'MONTH':
return "DATE_FORMAT($part, '%Y-%m')";
case 'DAY':
return "DATE_FORMAT($part, '%Y-%m-%d')";
case 'WEEK_0':
return "CONCAT(SUBSTRING(YEARWEEK($part, 6), 1, 4), '/', ".
"TRIM(LEADING '0' FROM SUBSTRING(YEARWEEK($part, 6), 5, 2)))";
case 'WEEK':
case 'WEEK_1':
return "CONCAT(SUBSTRING(YEARWEEK($part, 3), 1, 4), '/', ".
"TRIM(LEADING '0' FROM SUBSTRING(YEARWEEK($part, 3), 5, 2)))";
case 'QUARTER':
return "CONCAT(YEAR($part), '_', QUARTER($part))";
case 'MONTH_NUMBER':
$function = 'MONTH';
break;
case 'DATE_NUMBER':
$function = 'DAYOFMONTH';
break;
case 'YEAR_NUMBER':
$function = 'YEAR';
break;
case 'WEEK_NUMBER_0':
return "WEEK($part, 6)";
case 'WEEK_NUMBER':
case 'WEEK_NUMBER_1':
return "WEEK($part, 3)";
case 'HOUR_NUMBER':
$function = 'HOUR';
break;
case 'MINUTE_NUMBER':
$function = 'MINUTE';
break;
case 'SECOND_NUMBER':
$function = 'SECOND';
break;
case 'QUARTER_NUMBER':
$function = 'QUARTER';
break;
case 'DAYOFWEEK_NUMBER':
$function = 'DAYOFWEEK';
break;
case 'NOT':
return 'NOT ' . $part;
case 'TIMESTAMPDIFF_YEAR':
return 'TIMESTAMPDIFF(YEAR, ' . implode(', ', $argumentPartList) . ')';
case 'TIMESTAMPDIFF_MONTH':
return 'TIMESTAMPDIFF(MONTH, ' . implode(', ', $argumentPartList) . ')';
case 'TIMESTAMPDIFF_WEEK':
return 'TIMESTAMPDIFF(WEEK, ' . implode(', ', $argumentPartList) . ')';
case 'TIMESTAMPDIFF_DAY':
return 'TIMESTAMPDIFF(DAY, ' . implode(', ', $argumentPartList) . ')';
case 'TIMESTAMPDIFF_HOUR':
return 'TIMESTAMPDIFF(HOUR, ' . implode(', ', $argumentPartList) . ')';
case 'TIMESTAMPDIFF_MINUTE':
return 'TIMESTAMPDIFF(MINUTE, ' . implode(', ', $argumentPartList) . ')';
case 'TIMESTAMPDIFF_SECOND':
return 'TIMESTAMPDIFF(SECOND, ' . implode(', ', $argumentPartList) . ')';
case 'POSITION_IN_LIST':
return 'FIELD(' . implode(', ', $argumentPartList) . ')';
}
return $function . '(' . $part . ')';
}
/**
* @param string[] $argumentPartList
*/
private function getFunctionPartFromFactory(string $function, array $argumentPartList): string
{
assert($this->functionConverterFactory !== null);
$obj = $this->functionConverterFactory->create($function);
return $obj->convert(...$argumentPartList);
}
/**
* @param string[]|null $argumentPartList
*/
protected function getFunctionPartTZ(?array $argumentPartList = null): string
{
if (!$argumentPartList || count($argumentPartList) < 2) {
throw new RuntimeException("ORM Query: Not enough arguments for function TZ.");
}
$offsetHoursString = $argumentPartList[1];
if (str_starts_with($offsetHoursString, '\'') && str_ends_with($offsetHoursString, '\'')) {
$offsetHoursString = substr($offsetHoursString, 1, -1);
}
$offset = floatval($offsetHoursString);
$offsetHours = (int) (floor(abs($offset)));
$offsetMinutes = (abs($offset) - $offsetHours) * 60;
$offsetString =
str_pad((string) $offsetHours, 2, '0', STR_PAD_LEFT) .
':' .
str_pad((string) $offsetMinutes, 2, '0', STR_PAD_LEFT);
$offsetString = $offset < 0 ?
'-' . $offsetString :
'+' . $offsetString;
return "CONVERT_TZ(". $argumentPartList[0]. ", '+00:00', " . $this->quote($offsetString) . ")";
}
/**
* @param array<string, mixed> $params
*/
protected function convertComplexExpression(
?Entity $entity,
string $attribute,
bool $distinct,
array &$params
): string {
$function = null;
if (!$entity) {
$entity = $this->getSeed(null);
}
$entityType = $entity->getEntityType();
if (strpos($attribute, ':') && !Util::isArgumentString($attribute)) {
$delimiterPosition = (int) strpos($attribute, ':');
$function = substr($attribute, 0, $delimiterPosition);
$attribute = substr($attribute, $delimiterPosition + 1);
if (str_starts_with($attribute, '(') && str_ends_with($attribute, ')')) {
$attribute = substr($attribute, 1, -1);
}
}
if (!empty($function)) {
/** @noinspection PhpDeprecationInspection */
$function = strtoupper($this->sanitize($function));
}
if (!$function) {
return $this->getFunctionArgumentPart($entity, $attribute, $distinct, $params);
}
$argumentList = Util::parseArgumentListFromFunctionContent($attribute);
$argumentPartList = [];
foreach ($argumentList as $argument) {
$argumentPartList[] = $this->getFunctionArgumentPart($entity, $argument, $distinct, $params);
}
$part = implode(', ', $argumentPartList);
return $this->getFunctionPart(
$function,
$part,
$params,
$entityType,
$distinct,
$argumentPartList
);
}
/**
* @deprecated As of v6.0. Use `Util::getAllAttributesFromComplexExpression`.
* @return string[]
*/
public static function getAllAttributesFromComplexExpression(string $expression): array
{
return Util::getAllAttributesFromComplexExpression($expression);
}
/**
* @param array<string, mixed> $params
*/
protected function getFunctionArgumentPart(
Entity $entity,
string $attribute,
bool $distinct,
array &$params
): string {
$argument = $attribute;
if (Util::isArgumentString($argument)) {
$isSingleQuote = $argument[0] === "'";
$string = substr($argument, 1, -1);
$string = $isSingleQuote ?
str_replace("\\'", "'", $string) :
str_replace('\\"', '"', $string);
return $this->quote($string);
}
if (Util::isArgumentNumeric($argument)) {
if (filter_var($argument, FILTER_VALIDATE_INT) !== false) {
$argument = intval($argument);
} else if (filter_var($argument, FILTER_VALIDATE_FLOAT) !== false) {
$argument = floatval($argument);
}
return $this->quote($argument);
}
if (Util::isArgumentBoolOrNull($argument)) {
return strtoupper($argument);
}
if (strpos($argument, ':')) {
return $this->convertComplexExpression($entity, $argument, $distinct, $params);
}
$relName = null;
$entityType = $entity->getEntityType();
if (strpos($argument, '.')) {
[$relName, $attribute] = explode('.', $argument);
}
if (!empty($relName)) {
/** @noinspection PhpDeprecationInspection */
$relName = $this->sanitize($relName);
}
$isAlias = false;
if (!empty($attribute)) {
$isAlias = str_starts_with($attribute, '#');
/** @noinspection PhpDeprecationInspection */
$attribute = $isAlias ?
$this->sanitizeSelectAlias($attribute) :
$this->sanitize($attribute);
}
if ($attribute !== '') {
$part = !$isAlias ?
$this->toDb($attribute):
$attribute;
} else {
$part = '';
}
if ($relName) {
$part = $this->quoteColumn($relName . '.' . $part);
$foreignEntityType = $this->getRelationParam($entity, $relName, RelationParam::ENTITY);
if ($foreignEntityType) {
$foreignSeed = $this->getSeed($foreignEntityType);
$selectForeign = $this->getAttributeParam($foreignSeed, $attribute, 'selectForeign');
if (is_array($selectForeign)) {
$part = $this->getAttributeSql($foreignSeed, $attribute, 'selectForeign', $params, $relName);
}
}
return $part;
}
if (!$isAlias && $this->getAttributeParam($entity, $attribute, 'select')) {
return $this->getAttributeSql($entity, $attribute, 'select', $params);
}
if ($part === '') {
return $part;
}
if ($isAlias) {
return $this->quoteColumn($part);
}
$part = $this->getFromAlias($params, $entityType) . '.' . $part;
return $this->quoteColumn($part);
}
/**
* @param array<string, mixed>|null $params
*/
protected function getFromAlias(?array $params = null, ?string $entityType = null): string
{
$params = $params ?? [];
$alias = $params['fromAlias'] ?? null;
if ($alias) {
/** @noinspection PhpDeprecationInspection */
return $this->sanitize($alias);
}
$from = $params['from'] ?? null;
if ($from) {
return $this->toDb($from);
}
if ($entityType) {
return $this->toDb($entityType);
}
throw new RuntimeException();
}
/**
* @param array<string, mixed> $params
*/
protected function getAttributeOrderSql(
Entity $entity,
string $attribute,
array &$params,
string $order
): string {
$defs = $this->getAttributeParam($entity, $attribute, 'order') ?? [];
if (is_string($defs)) {
$defs = [];
}
if ($params) {
$this->applyAttributeCustomParams($defs, $params, $attribute);
}
if (is_string($this->getAttributeParam($entity, $attribute, 'order'))) {
// @deprecated
$part = $this->getAttributeParam($entity, $attribute, 'order');
return str_replace('{direction}', $order, $part);
}
if (!empty($defs['sql'])) {
// @deprecated
$part = $defs['sql'];
return str_replace('{direction}', $order, $part);
}
if (!empty($defs['order'])) {
if (!is_array($defs['order'])) {
throw new LogicException("Bad custom order definition.");
}
$modifiedOrder = [];
foreach ($defs['order'] as $item) {
if (!is_array($item) && !isset($item[0])) {
throw new LogicException("Bad custom order definition.");
}
$newItem = [
$item[0],
];
if (isset($item[1]) && $item[1] === '{direction}') {
$newItem[] = $order;
}
$modifiedOrder[] = $newItem;
}
/** @var string $part */
$part = $this->getOrderExpressionPart($entity, $modifiedOrder, null, $params, true);
return $part;
}
/** @noinspection PhpDeprecationInspection */
$part = $this->getFromAlias($params, $entity->getEntityType()) . '.' .
$this->toDb($this->sanitize($attribute));
$part = $this->quoteColumn($part);
$part .= ' ' . $order;
return $part;
}
/**
* @param array<string, mixed> $params
*/
protected function getAttributeSql(
Entity $entity,
string $attribute,
string $type,
array &$params = [],
?string $alias = null
): string {
$defs = $this->getAttributeParam($entity, $attribute, $type) ?? [];
if (is_string($defs)) {
$defs = [];
}
if ($params) {
$this->applyAttributeCustomParams($defs, $params, $attribute, $alias);
}
if (is_string($this->getAttributeParam($entity, $attribute, $type))) {
return $this->getAttributeParam($entity, $attribute, $type);
}
if (!empty($defs['sql'])) {
// @deprecated
$part = $defs['sql'];
if ($alias) {
$part = str_replace('{alias}', $alias, $part);
}
return $part;
}
if (!empty($defs['select'])) {
$expression = $defs['select'];
$alias = $alias ?? $this->getFromAlias($params, $entity->getEntityType());
$expression = str_replace('{alias}', $alias, $expression);
$pair = $this->getSelectPartItemPair($entity, $params, $expression);
if ($pair === null) {
throw new LogicException("Could not handle 'select'.");
}
return $pair[0];
}
$fromAlias = $this->getFromAlias($params, $entity->getEntityType());
/** @noinspection PhpDeprecationInspection */
$path = $fromAlias . '.' . $this->toDb($this->sanitize($attribute));
return $this->quoteColumn($path);
}
/**
* @param array<string, mixed> $defs
* @param array<string, mixed> $params
*/
protected function applyAttributeCustomParams(
array $defs,
array &$params,
string $attribute,
?string $alias = null
): void {
if (!empty($defs['leftJoins'])) {
foreach ($defs['leftJoins'] as $j) {
if (is_string($j)) {
$j = [$j];
}
$jAlias = $this->obtainJoinAlias($j);
if ($alias) {
$jAlias = str_replace('{alias}', $alias, $jAlias);
}
if (isset($j[1])) {
$j[1] = $jAlias;
}
foreach ($params['joins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
}
if ($alias && count($j) >= 3 && is_array($j[2])) {
$conditions = [];
foreach ($j[2] as $k => $value) {
if (is_string($value)) {
$value = str_replace('{alias}', $alias, $value);
}
/** @var string $left */
$left = $k;
$left = str_replace('{alias}', $alias, $left);
$conditions[$left] = $value;
}
$j[2] = $conditions;
}
$j[1] ??= null;
$j[2] ??= null;
$j[3] ??= [];
$j[3]['type'] = JoinType::left;
$params['joins'][] = $j;
}
}
if (!empty($defs['joins'])) {
foreach ($defs['joins'] as $j) {
if (is_string($j)) {
$j = [$j];
}
$jAlias = $this->obtainJoinAlias($j);
$jAlias = str_replace('{alias}', $alias ?? '', $jAlias);
if (isset($j[1])) {
$j[1] = $jAlias;
}
foreach ($params['joins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
}
if ($alias && count($j) >= 3 && is_array($j[2])) {
$conditions = [];
foreach ($j[2] as $k => $value) {
if (is_string($value)) {
$value = str_replace('{alias}', $alias, $value);
}
/** @var string $left */
$left = $k;
$left = str_replace('{alias}', $alias, $left);
$conditions[$left] = $value;
}
$j[2] = $conditions;
}
$j[1] ??= null;
$j[2] ??= null;
$j[3] ??= [];
$joinType = $j[3]['type'] ?? null;
$j[3]['type'] = $joinType ? JoinType::from($joinType) : JoinType::inner;
$params['joins'][] = $j;
}
}
// Some fields may need additional select items add to a query.
if (!empty($defs['additionalSelect'])) {
$params['extraAdditionalSelect'] = $params['extraAdditionalSelect'] ?? [];
foreach ($defs['additionalSelect'] as $value) {
if (is_string($value)) {
$value = str_replace('{alias}', $alias ?? '', $value);
}
$value = str_replace('{attribute}', $attribute, $value);
if (!in_array($value, $params['extraAdditionalSelect'])) {
$params['extraAdditionalSelect'][] = $value;
}
}
}
}
/**
* @param array<string, mixed> $params
* @return string[]
*/
protected function getOrderByAttributeList(array $params): array
{
$value = $params['orderBy'] ?? null;
if (!$value) {
return [];
}
if (is_numeric($value)) {
return [];
}
if (is_string($value)) {
$value = [[$value]];
}
if (!is_array($value)) {
return [];
}
$list = [];
foreach ($value as $item) {
if (!is_array($item) || !isset($item[0])) {
continue;
}
$expression = $item[0];
if (str_starts_with($expression, 'LIST:') && substr_count($expression, ':') === 2) {
$expression = explode(':', $expression)[1];
}
/** @noinspection PhpDeprecationInspection */
$attributeList = self::getAllAttributesFromComplexExpression($expression);
$list = array_merge(
$list,
$attributeList
);
}
return $list;
}
/**
*
* @param string[]|array<string[]> $itemList
* @param string[]|array<string[]> $newItemList
* @return string[]|array<string[]>
*/
protected function getNotIntersectingSelectItemList(array $itemList, array $newItemList): array
{
$list = [];
foreach ($newItemList as $newItem) {
$isMet = false;
foreach ($itemList as $item) {
$itemToCompare = is_array($item) ? ($item[0] ?? null) : $item;
if ($itemToCompare === $newItem) {
$isMet = true;
}
}
if (!$isMet) {
$list[] = $newItem;
}
}
return $list;
}
/**
* @param array<string, mixed> $params
*/
protected function getSelectPart(?Entity $entity, array &$params): string
{
$itemList = $params['select'] ?? [];
$selectNotSpecified = !count($itemList);
if (!$selectNotSpecified && self::isSelectAll($itemList) && $entity) {
array_shift($itemList);
foreach (array_reverse($entity->getAttributeList()) as $item) {
array_unshift($itemList, $item);
}
}
if ($selectNotSpecified && $entity) {
$itemList = $entity->getAttributeList();
}
if (empty($params['strictSelect']) && $entity && empty($params['groupBy'])) {
$itemList = array_merge(
$itemList,
$this->getSelectDependeeAdditionalList($entity, $itemList)
);
}
if (empty($params['strictSelect']) && !empty($params['distinct']) && empty($params['groupBy'])) {
$orderByAttributeList = $this->getOrderByAttributeList($params);
$itemList = array_merge(
$itemList,
$this->getNotIntersectingSelectItemList($itemList, $orderByAttributeList)
);
}
foreach ($itemList as $i => $item) {
if (is_string($item)) {
if (strpos($item, ':')) {
$itemList[$i] = [$item, $item];
}
}
}
$itemPairList = [];
foreach ($itemList as $item) {
$pair = $this->getSelectPartItemPair($entity, $params, $item);
if ($pair === null) {
continue;
}
$itemPairList[] = $pair;
}
if (!count($itemPairList)) {
throw new RuntimeException("ORM Query: Select part can't be empty.");
}
$selectPartItemList = [];
foreach ($itemPairList as $item) {
$expression = $item[0];
if ($expression === '') {
throw new RuntimeException("Bad select expression.");
}
if ($item[1] === '') {
$selectPartItemList[] = $expression;
continue;
}
/** @noinspection PhpDeprecationInspection */
$alias = $this->sanitizeSelectAlias($item[1]);
if ($alias === '') {
throw new RuntimeException("Bad alias.");
}
$selectPartItemList[] = "$expression AS " . $this->quoteIdentifier($alias);
}
return implode(', ', $selectPartItemList);
}
/**
* @param array<string, mixed> $params
* @param string|string[] $attribute
* @return array{string, string}|null
*/
protected function getSelectPartItemPair(?Entity $entity, array &$params, $attribute): ?array
{
$maxTextColumnsLength = $params['maxTextColumnsLength'] ?? null;
$skipTextColumns = $params['skipTextColumns'] ?? false;
$distinct = $params['distinct'] ?? false;
$attributeType = null;
if (!is_array($attribute) && !is_string($attribute)) { /** @phpstan-ignore-line */
throw new RuntimeException("ORM Query: Bad select item.");
}
if (is_array($attribute) && count($attribute) === 1) {
$attribute = $attribute[0];
}
if (is_string($attribute) && $entity) {
$attributeType = $entity->getAttributeType($attribute);
}
if ($skipTextColumns) {
if ($attributeType === Entity::TEXT) {
return null;
}
}
$expression = $attribute;
$alias = $expression;
if (is_array($attribute) && count($attribute)) {
$expression = $attribute[0];
if (count($attribute) >= 2) {
$alias = $attribute[1];
}
}
/** @var string $alias */
// @todo Make VALUE: usage deprecated.
if (is_string($expression) && stripos($expression, 'VALUE:') === 0) {
$part = $this->quote(
substr($expression, 6)
);
return [$part, $alias];
}
if (!$entity) {
if (!is_string($expression)) {
throw new RuntimeException();
}
return [
$this->convertComplexExpression(null, $expression, false, $params),
$alias
];
}
if (is_array($attribute) && count($attribute) === 1) {
$attribute = $attribute[0];
}
if (is_array($attribute) && count($attribute) === 2) {
// @todo Refactor to unite convertComplexExpression and select, noSelect, notStorable (here and below).
$alias = $attribute[1];
$attribute0 = $attribute[0];
if (!$entity->hasAttribute($attribute0)) {
$part = $this->convertComplexExpression($entity, $attribute0, $distinct, $params);
return [$part, $alias];
}
if ($this->getAttributeParam($entity, $attribute0, 'select')) {
$part = $this->getAttributeSql($entity, $attribute0, 'select', $params);
return [$part, $alias];
}
if ($this->getAttributeParam($entity, $attribute0, 'noSelect')) {
return null;
}
if (
$this->getAttributeParam($entity, $attribute0, AttributeParam::NOT_STORABLE) &&
$entity->getAttributeType($attribute0) !== Entity::FOREIGN
) {
return null;
}
/** @var string $part */
$part = $this->getAttributePath($entity, $attribute0, $params);
return [$part, $alias];
}
if (!is_string($attribute)) {
throw new RuntimeException("Bad select.");
}
if (!$entity->hasAttribute($attribute)) {
$expression = $attribute;
$part = $this->convertComplexExpression($entity, $expression, $distinct, $params);
return [$part, $attribute];
}
if ($this->getAttributeParam($entity, $attribute, 'select')) {
$fieldPath = $this->getAttributeSql($entity, $attribute, 'select', $params);
return [$fieldPath, $attribute];
}
if ($attributeType === null) {
return null;
}
if (
$this->getAttributeParam($entity, $attribute, AttributeParam::NOT_STORABLE) &&
$attributeType !== Entity::FOREIGN
) {
return null;
}
if ($attributeType === Entity::FOREIGN && $this->skipForeign($params)) {
return null;
}
/** @var string $fieldPath */
$fieldPath = $this->getAttributePath($entity, $attribute, $params);
if ($attributeType === Entity::TEXT && $maxTextColumnsLength !== null) {
$fieldPath = 'LEFT(' . $fieldPath . ', ' . $maxTextColumnsLength . ')';
}
return [$fieldPath, $attribute];
}
/**
* @param string[]|array<string[]> $itemList
* @return string[]|array<string[]>
*/
protected function getSelectDependeeAdditionalList(Entity $entity, array $itemList): array
{
$additionalList = [];
$itemListFiltered = array_filter(
$itemList,
function ($item) use ($entity) {
return is_string($item) && $entity->hasAttribute($item);
}
);
foreach ($itemListFiltered as $item) {
$additionalList = array_merge(
$additionalList,
$this->getAttributeParam($entity, $item, AttributeParam::DEPENDEE_ATTRIBUTE_LIST) ?? []
);
}
return array_filter(
$additionalList,
function ($item) use ($itemList) {
return !in_array($item, $itemList);
}
);
}
/**
* @param array<string, mixed>|null $params
*/
protected function getBelongsToJoinItemPart(
Entity $entity,
string $relationName,
?string $alias = null,
?array $params = null
): ?string {
$keySet = $this->helper->getRelationKeys($entity, $relationName);
$key = $keySet['key'];
$foreignKey = $keySet['foreignKey'];
/** @noinspection PhpDeprecationInspection */
$alias = !$alias ?
$this->getAlias($entity, $relationName) :
$this->sanitizeSelectAlias($alias);
if (!$alias) {
return null;
}
$foreignEntityType = $this->getRelationParam($entity, $relationName, RelationParam::ENTITY);
$table = $this->toDb($foreignEntityType);
$fromAlias = $this->getFromAlias($params, $entity->getEntityType());
$leftColumnPart = $this->quoteColumn("$fromAlias." . $this->toDb($key));
$rightColumnPart = $this->quoteColumn("$alias." . $this->toDb($foreignKey));
$hasDeleted = $this->metadata
->getDefs()
->tryGetEntity($foreignEntityType)
?->hasAttribute(Attribute::DELETED);
$tablePart = $this->quoteIdentifier($table);
$aliasPart = $this->quoteIdentifier($alias);
$part = "JOIN $tablePart AS $aliasPart ON $leftColumnPart = $rightColumnPart";
if ($hasDeleted) {
$deletedColumnPart = $this->quoteColumn("$alias." . Attribute::DELETED);
$deletedValuePart = $this->quote(false);
$part .= " AND $deletedColumnPart = $deletedValuePart";
}
return $part;
}
/**
* @param array<string, mixed> $params
*/
protected function getSelectTailPart(array $params): ?string
{
$forShare = $params['forShare'] ?? null;
$forUpdate = $params['forUpdate'] ?? null;
if ($forShare) {
return "LOCK IN SHARE MODE";
}
if ($forUpdate) {
return "FOR UPDATE";
}
return null;
}
/**
* @param string[]|array<string[]> $select
* @param string[]|array<int, mixed>[] $explicitJoins
* @param array<string, mixed> $params
*/
protected function getBelongsToJoinsPart(
Entity $entity,
?array $select,
array $explicitJoins,
array $params,
): string {
$joinsArr = [];
$relationsToJoin = [];
if (is_array($select)) {
foreach ($select as $item) {
$field = $item;
if (is_array($item)) {
if (count($item) == 0) {
continue;
}
$field = $item[0];
}
/** @var string $field */
if (
$entity->getAttributeType($field) == AttributeType::FOREIGN &&
$this->getAttributeParam($entity, $field, AttributeParam::RELATION)
) {
$relationsToJoin[] = $this->getAttributeParam($entity, $field, AttributeParam::RELATION);
} else if (
$this->getAttributeParam($entity, $field, 'fieldType') == FieldType::LINK_ONE &&
$this->getAttributeParam($entity, $field, AttributeParam::RELATION)
) {
$relationsToJoin[] = $this->getAttributeParam($entity, $field, AttributeParam::RELATION);
}
}
}
foreach ($entity->getRelationList() as $relationName) {
$type = $entity->getRelationType($relationName);
if ($type !== Entity::BELONGS_TO && $type !== Entity::HAS_ONE) {
continue;
}
if ($this->getRelationParam($entity, $relationName, RelationParam::NO_JOIN)) {
continue;
}
if (in_array($relationName, $explicitJoins)) {
// Never suppose to happen.
continue;
} else {
foreach ($explicitJoins as $skipItem) {
if (!is_array($skipItem)) {
continue;
}
if (
($skipItem[0] ?? null) === $relationName &&
(
($skipItem[1] ?? null) === null ||
($skipItem[1] ?? null) === $relationName
)
) {
continue 2;
}
}
}
foreach ($explicitJoins as $sItem) {
if (is_array($sItem) && count($sItem) > 1) {
if ($sItem[1] === $relationName) {
continue 2;
}
}
}
if (
is_array($select) &&
!self::isSelectAll($select) &&
!in_array($relationName, $relationsToJoin)
) {
continue;
}
if ($type === Entity::BELONGS_TO) {
$join = $this->getBelongsToJoinItemPart($entity, $relationName, null, $params);
if (!$join) {
continue;
}
$joinsArr[] = 'LEFT ' . $join;
continue;
}
// HAS_ONE
$join = $this->getJoinItemPart(
$entity,
$relationName,
true,
[],
null,
[],
$params,
);
$joinsArr[] = $join;
}
return implode(' ', $joinsArr);
}
/**
* @param array<int, string[]|string> $select
*/
protected static function isSelectAll(array $select): bool
{
if (!count($select)) {
return true;
}
return $select[0] === '*' || $select[0][0] === '*';
}
/**
* @param array<string, mixed> $params
* @param mixed $orderBy
* @param mixed $order
*/
protected function getOrderExpressionPart(
Entity $entity,
$orderBy = null,
$order = null,
array &$params = [],
bool $noCustom = false
): ?string {
if (is_null($orderBy)) {
return null;
}
if (is_array($orderBy)) {
$arr = [];
foreach ($orderBy as $item) {
if (is_array($item)) {
$orderByInternal = $item[0];
$orderInternal = null;
if (!empty($item[1])) {
$orderInternal = $item[1];
}
$arr[] = $this->getOrderExpressionPart(
$entity,
$orderByInternal,
$orderInternal,
$params,
$noCustom
);
}
}
return implode(", ", $arr);
}
if (str_starts_with($orderBy, 'LIST:')) {
[, $field, $listString] = explode(':', $orderBy);
$list = explode(',', $listString);
$list = array_map(fn($item) => str_replace('_COMMA_', ',', $item), $list);
$list = array_map(fn($item) => $this->quote($item), $list);
$list = array_reverse($list);
$listString = implode(', ', $list);
$orderBy = "POSITION_IN_LIST:($field, $listString)";
$order = 'DESC';
}
if (!is_null($order)) {
if (is_bool($order)) {
$order = $order ? 'DESC' : 'ASC';
}
$order = strtoupper($order);
if (!in_array($order, ['ASC', 'DESC'])) {
$order = 'ASC';
}
} else {
$order = 'ASC';
}
if (is_integer($orderBy)) {
return "$orderBy " . $order;
}
if (
!$noCustom &&
$entity->hasAttribute($orderBy) &&
$this->getAttributeParam($entity, $orderBy, 'order')
) {
return $this->getAttributeOrderSql($entity, $orderBy, $params, $order);
}
$fieldPath = $this->getAttributePathForOrderBy($entity, $orderBy, $params);
if ($fieldPath === null || $fieldPath === '') {
throw new LogicException("Could not handle 'order' for '".$entity->getEntityType()."'.");
}
return "$fieldPath " . $order;
}
/**
* @param array<string, mixed> $params
* @param mixed $orderBy
* @param mixed $order
*/
protected function getOrderPart(Entity $entity, $orderBy = null, $order = null, &$params = []): ?string
{
return $this->getOrderExpressionPart($entity, $orderBy, $order, $params);
}
/**
* @param array<string, mixed> $params
*/
protected function getAttributePathForOrderBy(Entity $entity, string $orderBy, array $params): ?string
{
if (Util::isComplexExpression($orderBy)) {
return $this->convertComplexExpression(
$entity,
$orderBy,
false,
$params
);
}
return $this->getAttributePath($entity, $orderBy, $params);
}
/**
* Quote a value (if needed).
* @param mixed $value
*/
protected function quote($value): string
{
if (is_null($value)) {
return 'NULL';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_int($value)) {
return strval($value);
}
if (is_float($value)) {
return strval($value);
}
return $this->pdo->quote($value);
}
/**
* Converts field and entity names to a form required for database.
*/
protected function toDb(string $string): string
{
if (!array_key_exists($string, $this->attributeDbMapCache)) {
$string[0] = strtolower($string[0]);
/** @var string $dbString */
$dbString = preg_replace_callback(
'/([A-Z])/',
fn($matches) => '_' . strtolower($matches[1]),
$string
);
$this->attributeDbMapCache[$string] = $dbString;
}
return $this->attributeDbMapCache[$string];
}
protected function getAlias(Entity $entity, string $relationName): ?string
{
if (!isset($this->aliasesCache[$entity->getEntityType()])) {
$this->aliasesCache[$entity->getEntityType()] = $this->getTableAliases($entity);
}
if (isset($this->aliasesCache[$entity->getEntityType()][$relationName])) {
return $this->aliasesCache[$entity->getEntityType()][$relationName];
}
return null;
}
/**
* @return array<string, string>
*/
protected function getTableAliases(Entity $entity): array
{
$aliases = [];
$occurrenceHash = [];
foreach ($entity->getRelationList() as $name) {
$type = $entity->getRelationType($name);
if (
($type === Entity::BELONGS_TO || $type === Entity::HAS_ONE) &&
!array_key_exists($name, $aliases)
) {
if (array_key_exists($name, $occurrenceHash)) {
$occurrenceHash[$name]++;
} else {
$occurrenceHash[$name] = 0;
}
$suffix = '';
if ($occurrenceHash[$name] > 0) {
$suffix .= '_' . $occurrenceHash[$name];
}
$aliases[$name] = $name . $suffix;
}
}
return $aliases;
}
/**
* @param array<string, mixed> $params
*/
protected function getAttributePath(Entity $entity, string $attribute, array &$params): ?string
{
if (!$entity->hasAttribute($attribute)) {
return null;
}
$entityType = $entity->getEntityType();
$attributeType = $entity->getAttributeType($attribute);
if ($this->getAttributeParam($entity, $attribute, 'source')) {
// For bc.
if ($this->getAttributeParam($entity, $attribute, 'source') !== 'db') {
return null;
}
}
if (
$this->getAttributeParam($entity, $attribute, AttributeParam::NOT_STORABLE) &&
$attributeType !== Entity::FOREIGN
) {
return null;
}
switch ($attributeType) {
case Entity::FOREIGN:
$relationName = $this->getAttributeParam($entity, $attribute, AttributeParam::RELATION);
if (!$relationName) {
return null;
}
$foreign = $this->getAttributeParam($entity, $attribute, AttributeParam::FOREIGN);
if (is_array($foreign)) {
$wsCount = 0;
foreach ($foreign as $i => $value) {
if ($value == ' ') {
$foreign[$i] = '\' \'';
$wsCount ++;
continue;
}
$item = $this->getAlias($entity, $relationName) . '.' . $this->toDb($value);
$item = $this->quoteColumn($item);
$foreign[$i] = "COALESCE($item, '')";
}
$path = 'TRIM(CONCAT(' . implode(', ', $foreign). '))';
if ($wsCount > 1) {
$path = "REPLACE($path, ' ', ' ')";
}
return "NULLIF($path, '')";
}
$expression = $this->getAlias($entity, $relationName) . '.' . $foreign;
return $this->convertComplexExpression($entity, $expression, false, $params);
}
$alias = $this->getFromAlias($params, $entityType);
/** @noinspection PhpDeprecationInspection */
$path = $alias . '.' . $this->toDb($this->sanitize($attribute));
return $this->quoteColumn($path);
}
/**
* @param array<string, mixed> $params
* @param array<string|int, mixed> $whereClause
*/
protected function getWherePart(
Entity $entity,
?array $whereClause = null,
string $sqlOp = 'AND',
array &$params = [],
int $level = 0,
bool $noCustomWhere = false
): string {
$wherePartList = [];
$whereClause = $whereClause ?? [];
foreach ($whereClause as $field => $value) {
$partItem = $this->getWherePartItem($entity, $field, $value, $params, $level, $noCustomWhere);
if ($partItem === null) {
continue;
}
$wherePartList[] = $partItem;
}
return implode(" " . $sqlOp . " ", $wherePartList);
}
/**
* @return array{string, string, string}
*/
private function splitWhereLeftItem(string $item): array
{
if (preg_match('/^[a-z0-9]+$/i', $item)) {
return [$item, '=', '='];
}
foreach ($this->comparisonOperators as $operator) {
$sqlOperator = $this->comparisonOperatorMap[$operator] ?? $operator;
if (!str_ends_with($item, $operator)) {
continue;
}
$expression = trim(substr($item, 0, -strlen($operator)));
return [$expression, $sqlOperator, $operator];
}
return [$item, '=', '='];
}
/**
* @param array<string, mixed> $params
*/
protected function getWherePartItem(
Entity $entity,
mixed $leftKey,
mixed $right,
array &$params,
int $level,
bool $noCustomWhere = false
): ?string {
if (is_int($leftKey) && is_string($right)) {
return $this->convertComplexExpression($entity, $right, false, $params);
}
$left = $leftKey;
if (is_int($left)) {
$left = 'AND';
}
if ($leftKey === 'NOT') {
$left = 'AND';
}
if (in_array($left, self::SQL_OPERATORS)) {
$internalPart = $this->getWherePart($entity, $right, $left, $params, $level + 1);
if (!$internalPart && $internalPart !== '0') {
return null;
}
if ($leftKey === 'NOT') {
return "NOT (" . $internalPart . ")";
}
return "(" . $internalPart . ")";
}
if ($left === self::EXISTS_OPERATOR) {
if ($right instanceof Select) {
$subQueryPart = $this->composeSelect($right);
} else if (is_array($right)) {
$subQueryPart = $this->createSelectQueryInternal($right);
} else {
throw new RuntimeException("Bad EXISTS usage in where-clause.");
}
return "EXISTS ($subQueryPart)";
}
$isComplex = false;
$isNotValue = false;
if (str_ends_with($left, ':')) {
$left = substr($left, 0, strlen($left) - 1);
$isNotValue = true;
}
[$left, $operator, $operatorOrm] = $this->splitWhereLeftItem($left);
$leftPart = null;
if (Util::isComplexExpression($left)) {
$leftPart = $this->convertComplexExpression($entity, $left, false, $params);
$isComplex = true;
}
if (!$isComplex) {
if (!$entity->hasAttribute($left)) {
return $this->quote(false);
}
$operatorKey = $this->getWhereOperatorKey(
$operator,
$operatorOrm,
$right,
$entity->getAttributeType($left)
);
if (
!$noCustomWhere &&
$this->getAttributeParam($entity, $left, 'where') &&
isset($this->getAttributeParam($entity, $left, 'where')[$operatorKey])
) {
$whereDefs = $this->getAttributeParam($entity, $left, 'where')[$operatorKey];
return $this->getWherePartItemCustom($entity, $right, $whereDefs, $params, $level);
}
$leftPart = $this->getWherePartItemAttributeLeftPart($entity, $left, $params);
}
return $this->addRightWherePartItem(
leftPart: $leftPart,
operatorOrm: $operatorOrm,
operator: $operator,
right: $right,
entity: $entity,
isNotValue: $isNotValue,
params: $params,
);
}
/**
* @param array<string, mixed> $params
*/
private function addRightWherePartItem(
?string $leftPart,
string $operatorOrm,
string $operator,
mixed $right,
Entity $entity,
bool $isNotValue,
array $params,
): string {
if ($leftPart === null) {
return $this->quote(false);
}
if ($operatorOrm === '=s' || $operatorOrm === '!=s') {
if ($right instanceof Select) {
$subSql = $this->composeSelect($right);
return "$leftPart $operator ($subSql)";
}
if (!is_array($right)) {
throw new RuntimeException("Bad `=s` operator usage, value must be sub-query.");
}
$subQuerySelectParams = !empty($right['selectParams']) ?
$right['selectParams'] :
$right;
if (
!isset($subQuerySelectParams['from']) &&
!isset($subQuerySelectParams['fromQuery'])
) {
// 'entityType' is for backward compatibility.
$subQuerySelectParams['from'] = $right['entityType'] ?? $entity->getEntityType();
}
if (!empty($right['withDeleted'])) {
$subQuerySelectParams['withDeleted'] = true;
}
$subSql = $this->createSelectQueryInternal($subQuerySelectParams);
return "$leftPart $operator ($subSql)";
}
if ($right instanceof Select) {
if ($operatorOrm === '*' || $operatorOrm === '!*') {
throw new RuntimeException("LIKE operator is not compatible with sub-query.");
}
$subQueryPart = $this->composeSelect($right);
return "$leftPart $operator ($subQueryPart)";
}
if (str_ends_with($operatorOrm, 'any') || str_ends_with($operatorOrm, 'all')) {
throw new RuntimeException("ANY/ALL operators can be used only with sub-query.");
}
if ($right instanceof Expression) {
$isNotValue = true;
$right = $right->getValue();
}
if (is_array($right)) {
$valuePartList = $right;
foreach ($valuePartList as $k => $v) {
$valuePartList[$k] = $this->quote($v);
}
$negatingPart = '';
$emptyValuePart = $this->quote(false);
if ($operator === '<>') {
$negatingPart = 'NOT ';
$emptyValuePart = $this->quote(true);
}
if ($valuePartList === []) {
return $emptyValuePart;
}
$valuesPart = implode(',', $valuePartList);
return "$leftPart {$negatingPart}IN ($valuesPart)";
}
if ($isNotValue) {
if (is_null($right)) {
return $leftPart;
}
$expressionSql = $this->convertComplexExpression($entity, $right, false, $params);
return "$leftPart $operator $expressionSql";
}
if (is_null($right)) {
if ($operator === '=') {
return "$leftPart IS NULL";
}
if ($operator === '<>') {
return "$leftPart IS NOT NULL";
}
return $this->quote(false);
}
$valuePart = $this->quote($right);
return "$leftPart $operator $valuePart";
}
/**
* @param array<string, mixed> $params
*/
private function getWherePartItemAttributeLeftPart(Entity $entity, string $attribute, array &$params): ?string
{
$attributeType = $entity->getAttributeType($attribute);
$entityType = $entity->getEntityType();
if ($attributeType === Entity::FOREIGN) {
// @todo Add a test.
$relationName = $this->getAttributeParam($entity, $attribute, AttributeParam::RELATION);
$foreign = $this->getAttributeParam($entity, $attribute, AttributeParam::FOREIGN);
if (!$relationName) {
throw new RuntimeException("No 'relation' param for field $entityType.$attribute.");
}
if (!$foreign) {
throw new RuntimeException("No 'foreign' param for field $entityType.$attribute.");
}
if (!$entity->hasRelation($relationName)) {
throw new RuntimeException("No relation '$relationName' for field $entityType.$attribute.");
}
$alias = $this->getAlias($entity, $relationName);
if (!$alias) {
throw new RuntimeException("Could not get alias for $entityType.$relationName.");
}
if (is_array($foreign)) {
return $this->getAttributePath($entity, $attribute, $params);
}
return $this->convertComplexExpression($entity, "$alias.$foreign", false, $params);
}
$fromAlias = $this->getFromAlias($params, $entity->getEntityType());
/** @noinspection PhpDeprecationInspection */
$column = $fromAlias . '.' . $this->toDb($this->sanitize($attribute));
return $this->quoteColumn($column);
}
private function getWhereOperatorKey(
string $operator,
string $operatorOrm,
mixed $value,
?string $attributeType
): string {
$operatorKey = $operator;
if ($operatorOrm === '*') {
$operatorKey = 'LIKE';
} else if ($operatorOrm === '!*') {
$operatorKey = 'NOT LIKE';
}
if (
is_bool($value) &&
in_array($operator, ['=', '<>']) &&
$attributeType == Entity::BOOL
) {
if ($value) {
$operatorKey = $operator === '=' ?
'= TRUE' : '= FALSE';
} else {
$operatorKey = $operator === '=' ?
'= FALSE' : '= TRUE';
}
} else if (is_array($value)) {
if ($operator == '=') {
$operatorKey = 'IN';
} else if ($operator == '<>') {
$operatorKey = 'NOT IN';
}
} else if (is_null($value)) {
if ($operator == '=') {
$operatorKey = 'IS NULL';
} else if ($operator == '<>') {
$operatorKey = 'IS NOT NULL';
}
}
return $operatorKey;
}
/**
* @param array<string, mixed>|string $whereDefs
* @param array<string, mixed> $params
*/
protected function getWherePartItemCustom(
Entity $entity,
mixed $value,
array|string $whereDefs,
array &$params,
int $level
): string {
$whereSqlPart = '';
$whereClause = null;
if (is_string($whereDefs)) {
$whereSqlPart = $whereDefs;
$whereDefs = [];
} else if (!empty($whereDefs['sql'])) {
$whereSqlPart = $whereDefs['sql'];
} else if (!empty($whereDefs['whereClause'])) {
$whereClause = $this->applyValueToCustomWhereClause($whereDefs['whereClause'], $value);
} else {
return $this->quote(false);
}
$leftJoins = $whereDefs['leftJoins'] ?? [];
$joins = $whereDefs['joins'] ?? [];
foreach ($leftJoins as $j) {
$jAlias = $this->obtainJoinAlias($j);
if (is_string($j)) {
$j = [$j];
}
foreach ($params['joins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
}
$j[1] ??= null;
$j[2] ??= null;
$j[3] ??= [];
$j[3]['type'] = JoinType::left;
$params['joins'][] = $j;
}
foreach ($joins as $j) {
$jAlias = $this->obtainJoinAlias($j);
if (is_string($j)) {
$j = [$j];
}
foreach ($params['joins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
}
$j[1] ??= null;
$j[2] ??= null;
$j[3] ??= [];
$joinType = $j[3]['type'] ?? null;
$j[3]['type'] = $joinType ? JoinType::from($joinType) : JoinType::inner;
$params['joins'][] = $j;
}
if (!empty($whereDefs['customJoin'])) {
// For bc.
$params['customJoin'] .= ' ' . $whereDefs['customJoin'];
}
if (!empty($whereDefs['distinct'])) {
$params['distinct'] = true;
}
if ($whereClause) {
return
"(" .
$this->getWherePart($entity, $whereClause, 'AND', $params, $level, true) .
")";
}
return str_replace('{value}', $this->stringifyValue($value), $whereSqlPart);
}
/**
* @param array<string|int, mixed> $whereClause
* @return array<string|int, mixed>
*/
protected function applyValueToCustomWhereClause(array $whereClause, mixed $value): array
{
$modified = [];
foreach ($whereClause as $left => $right) {
if ($right === '{value}') {
$right = $value;
} else if (is_string($right)) {
if (!is_array($value)) {
$right = str_replace('{value}', (string) $value, $right);
}
} else if (is_array($right)) {
$right = $this->applyValueToCustomWhereClause($right, $value);
}
if (is_string($left) && str_ends_with($left, ':') && str_contains($left, '{value}')) {
$left = str_replace('{value}', Expression\Util::stringifyArgument($value), $left);
}
$modified[$left] = $right;
}
return $modified;
}
/**
* @param array<string>|string $j
* @return string
*/
protected function obtainJoinAlias($j)
{
if (is_array($j)) {
if (isset($j[0])) {
if (isset($j[1]) && $j[1]) {
$joinAlias = $j[1];
} else {
$joinAlias = $j[0];
}
} else {
$joinAlias = $j[0];
}
} else {
$joinAlias = $j;
}
return $joinAlias;
}
/**
* @param mixed $value
*/
protected function stringifyValue($value): string
{
if (is_array($value)) {
$arr = [];
foreach ($value as $v) {
$arr[] = $this->quote($v);
}
$stringValue = '(' . implode(', ', $arr) . ')';
} else {
$stringValue = $this->quote($value);
}
return $stringValue;
}
/**
* Sanitize a string.
* @todo Make protected in v10.0.
* @deprecated As of v6.0. Not to be used outside.
*/
public function sanitize(string $string): string
{
return preg_replace('/[^A-Za-z0-9_]+/', '', $string) ?? '';
}
/**
* Sanitize an alias for a SELECT statement.
* @todo Make protected in v10.0.
* @deprecated As of v6.0. Not to be used outside.
*/
public function sanitizeSelectAlias(string $string): string
{
$string = preg_replace('/[^A-Za-z\r\n0-9_:\'" .,\-()]+/', '', $string) ?? '';
if (strlen($string) > $this->aliasMaxLength) {
$string = substr($string, 0, $this->aliasMaxLength);
}
return $string;
}
protected function sanitizeIndexName(string $string): string
{
return preg_replace('/[^A-Za-z0-9_]+/', '', $string) ?? '';
}
/**
* @param array<string|int, mixed> $joinConditions
* @param array<string, mixed[]> $joins
* @param array<string, mixed> $params
*/
protected function getJoinsTypePart(
Entity $entity,
array $joins,
array $params,
$joinConditions,
bool $isLeft = false,
): string {
$joinSqlList = [];
foreach ($joins as $item) {
$itemConditions = [];
$itemParams = [];
$isItemLeft = $isLeft;
if (is_array($item)) {
$target = $item[0];
if (count($item) > 1) {
$alias = $item[1] ?? $target;
if (count($item) > 2) {
$itemConditions = $item[2] ?? [];
}
if (count($item) > 3) {
$itemParams = $item[3] ?? [];
}
} else {
$alias = $target;
}
if (($itemParams['type'] ?? null) === JoinType::left) {
$isItemLeft = true;
}
if ($target instanceof Select && !is_string($alias)) {
throw new LogicException("Sub-query join can't be w/o alias");
}
} else {
$target = $item;
$alias = $target;
}
$conditions = [];
if (!empty($joinConditions[$alias])) {
$conditions = $joinConditions[$alias];
}
foreach ($itemConditions as $left => $right) {
$conditions[$left] = $right;
}
$sql = $this->getJoinItemPart(
entity: $entity,
target: $target,
isLeft: $isItemLeft,
conditions: $conditions,
alias: $alias,
joinParams: $itemParams,
params: $params,
);
if ($sql) {
$joinSqlList[] = $sql;
}
}
return implode(' ', $joinSqlList);
}
/**
* @param array<string, mixed> $params
* @noinspection PhpDeprecationInspection
*/
protected function buildJoinConditionStatement(
Entity $entity,
string $alias,
mixed $leftKey,
mixed $right,
array $params,
bool $noLeftAlias = false
): string {
// @todo Unify with `getWherePartItem`. If reasonable.
$left = $leftKey;
if (is_int($left)) {
$left = 'AND';
}
if ($leftKey === 'NOT') {
$left = 'AND';
}
if (in_array($left, self::SQL_OPERATORS)) {
$logicalOperator = 'AND';
if ($left === 'OR') {
$logicalOperator = 'OR';
}
$parts = [];
foreach ($right as $k => $v) {
$part = $this->buildJoinConditionStatement($entity, $alias, $k, $v, $params, $noLeftAlias);
if ($part === '') {
continue;
}
$parts[] = $part;
}
$sql = implode(' ' . $logicalOperator . ' ', $parts);
if (count($parts) > 1) {
$sql = '(' . $sql . ')';
}
if ($leftKey === 'NOT') {
return "NOT ($sql)";
}
return $sql;
}
$isNotValue = false;
$isComplex = false;
if (str_ends_with($left, ':')) {
$left = substr($left, 0, strlen($left) - 1);
$isNotValue = true;
}
[$left, $operator, $operatorOrm] = $this->splitWhereLeftItem($left);
$leftPart = null;
if (Util::isComplexExpression($left)) {
$isComplex = true;
// Differs from where logic.
$stub = []; // @todo Revise the need.
$leftPart = $this->convertComplexExpression($entity, $left, false, $stub);
}
if (!$isComplex) {
// Differs from where logic.
$column = $this->toDb($this->sanitize($left));
$leftAlias = $noLeftAlias ?
$this->getFromAlias($params, $entity->getEntityType()) :
$this->sanitize($alias);
$leftPart = $this->quoteColumn("$leftAlias.$column");
}
return $this->addRightWherePartItem(
leftPart: $leftPart,
operatorOrm: $operatorOrm,
operator: $operator,
right: $right,
entity: $entity,
isNotValue: $isNotValue,
params: $params,
);
}
/**
* @param array<string, mixed> $params
* @param array<string, mixed> $joinParams
* @param array<string|int, mixed> $conditions
*/
protected function getJoinItemPart(
Entity $entity,
string|Select $target,
bool $isLeft = false,
array $conditions = [],
?string $alias = null,
array $joinParams = [],
array $params = []
): string {
$prefixPart = $isLeft ? 'LEFT ' : '';
if (!is_string($target) || !$entity->hasRelation($target)) {
if ($alias === '') {
throw new LogicException("Empty alias.");
}
if (!is_string($target)) {
if ($alias === null) {
throw new LogicException();
}
/** @noinspection PhpDeprecationInspection */
$alias = $this->sanitizeSelectAlias($alias);
} else {
/** @noinspection PhpDeprecationInspection */
$alias = $alias === null ?
$this->sanitize($target) :
$this->sanitizeSelectAlias($alias);
}
/** @noinspection PhpDeprecationInspection */
$targetPart = is_string($target) ?
$this->quoteIdentifier($this->toDb($this->sanitize($target))) :
'(' . $this->composeSelecting($target) . ')';
$aliasPart = $this->quoteIdentifier($alias);
$sql = $prefixPart . "JOIN ";
if (!empty($joinParams['isLateral'])) {
$sql .= "LATERAL ";
}
$sql .= "$targetPart AS $aliasPart";
if ($conditions === []) {
return $sql;
}
$sql .= " ON";
$conditionParts = [];
foreach ($conditions as $left => $right) {
$conditionParts[] = $this->buildJoinConditionStatement(
$entity,
$alias,
$left,
$right,
$params,
$joinParams['noLeftAlias'] ?? false,
);
}
$sql .= " " . implode(" AND ", $conditionParts);
return $sql;
}
$relationName = $target;
$keySet = $this->helper->getRelationKeys($entity, $relationName);
if (!$alias) {
$alias = $relationName;
}
/** @noinspection PhpDeprecationInspection */
$alias = $this->sanitize($alias);
$relationConditions = $this->getRelationParam($entity, $relationName, RelationParam::CONDITIONS);
$foreignEntityType = $this->getRelationParam($entity, $relationName, RelationParam::ENTITY);
if ($relationConditions) {
$conditions = array_merge($conditions, $relationConditions);
}
$type = $entity->getRelationType($relationName);
$fromAlias = $this->getFromAlias($params, $entity->getEntityType());
switch ($type) {
case Entity::MANY_MANY:
$key = $keySet['key'];
$foreignKey = $keySet['foreignKey'];
$nearKey = $keySet['nearKey'] ?? null;
$distantKey = $keySet['distantKey'] ?? null;
if ($nearKey === null || $distantKey === null) {
throw new RuntimeException("Bad relation key.");
}
$relTable = $this->toDb(
$this->getRelationParam($entity, $relationName, RelationParam::RELATION_NAME)
);
$distantTable = $this->toDb($foreignEntityType);
$onlyMiddle = $joinParams['onlyMiddle'] ?? false;
$midAlias = $onlyMiddle ?
$alias :
$alias . 'Middle';
$indexKeyList = null;
$indexList = $joinParams['useIndex'] ?? null;
if ($indexList) {
$indexKeyList = [];
if (is_string($indexList)) {
$indexList = [$indexList];
}
foreach ($indexList as $indexName) {
$indexKey = $this->metadata->get(
$entity->getEntityType(),
[EntityParam::RELATIONS, $relationName, RelationParam::INDEXES, $indexName, IndexParam::KEY]
);
if ($indexKey) {
$indexKeyList[] = $indexKey;
}
}
}
$indexPart = '';
if ($this->indexHints && $indexKeyList !== null && count($indexKeyList)) {
$sanitizedIndexList = [];
foreach ($indexKeyList as $indexKey) {
$sanitizedIndexList[] = $this->quoteIdentifier(
$this->sanitizeIndexName($indexKey)
);
}
$indexPart = " USE INDEX (" . implode(', ', $sanitizedIndexList) . ")";
}
$leftKeyColumn = $this->quoteColumn("$fromAlias." . $this->toDb($key));
$middleKeyColumn = $this->quoteColumn("$midAlias." . $this->toDb($nearKey));
$middleDeletedColumn = $this->quoteColumn("$midAlias.deleted");
$sql =
"{$prefixPart}JOIN ".$this->quoteIdentifier($relTable)." AS " .
$this->quoteIdentifier($midAlias) . "$indexPart " .
"ON $leftKeyColumn = $middleKeyColumn" .
" AND " .
"$middleDeletedColumn = " . $this->quote(false);
$conditionParts = [];
foreach ($conditions as $left => $right) {
$conditionParts[] = $this->buildJoinConditionStatement(
$entity,
$midAlias,
$left,
$right,
$params
);
}
if (count($conditionParts)) {
$sql .= " AND " . implode(" AND ", $conditionParts);
}
if (!$onlyMiddle) {
$rightKeyColumn = $this->quoteColumn("$alias." . $this->toDb($foreignKey));
$middleDistantKeyColumn = $this->quoteColumn("$midAlias." . $this->toDb($distantKey));
$rightDeletedColumn = $this->quoteColumn("$alias.deleted");
$sql .= " {$prefixPart}JOIN " . $this->quoteIdentifier($distantTable) . " AS " .
$this->quoteIdentifier($alias)
. " ON $rightKeyColumn = $middleDistantKeyColumn"
. " AND "
. "$rightDeletedColumn = " . $this->quote(false);
}
return $sql;
case Entity::HAS_MANY:
case Entity::HAS_ONE:
$foreignKey = $keySet['foreignKey'];
$distantTable = $this->toDb($foreignEntityType);
$leftIdColumn = $this->quoteColumn("$fromAlias." . $this->toDb(Attribute::ID));
$rightIdColumn = $this->quoteColumn("$alias." . $this->toDb($foreignKey));
$leftDeletedColumn = $this->quoteColumn("$alias.deleted");
$sql =
"{$prefixPart}JOIN " . $this->quoteIdentifier($distantTable) . " AS "
. $this->quoteIdentifier($alias) . " ON "
. "$leftIdColumn = $rightIdColumn AND "
. "$leftDeletedColumn = " . $this->quote(false);
$conditionParts = [];
foreach ($conditions as $left => $right) {
$conditionParts[] = $this->buildJoinConditionStatement($entity, $alias, $left, $right, $params);
}
if (count($conditionParts)) {
$sql .= " AND " . implode(" AND ", $conditionParts);
}
return $sql;
case Entity::HAS_CHILDREN:
$foreignKey = $keySet['foreignKey'];
$foreignType = $keySet['foreignType'] ?? null;
if ($foreignType === null) {
throw new RuntimeException("Bad relation key.");
}
$distantTable = $this->toDb($foreignEntityType);
$leftIdColumn = $this->quoteColumn("$fromAlias." . $this->toDb(Attribute::ID));
$rightIdColumn = $this->quoteColumn("$alias." . $this->toDb($foreignKey));
$leftTypeColumn = $this->quoteColumn("$alias." . $this->toDb($foreignType));
$leftDeletedColumn = $this->quoteColumn("$alias.deleted");
$sql =
"{$prefixPart}JOIN " . $this->quoteIdentifier($distantTable)
. " AS "
. $this->quoteIdentifier($alias) . " ON "
. "$leftIdColumn = $rightIdColumn AND "
. "$leftTypeColumn = " . $this->quote($entity->getEntityType()) . " AND "
. "$leftDeletedColumn = " . $this->quote(false);
$conditionParts = [];
foreach ($conditions as $left => $right) {
$conditionParts[] = $this->buildJoinConditionStatement($entity, $alias, $left, $right, $params);
}
if (count($conditionParts)) {
$sql .= " AND " . implode(" AND ", $conditionParts);
}
return $sql;
case Entity::BELONGS_TO:
return $prefixPart . $this->getBelongsToJoinItemPart($entity, $relationName, $alias, $params);
}
return '';
}
/**
* @param string[]|null $indexKeyList
*/
protected function composeSelectQuery(
?string $from,
string $select,
?string $alias = null,
?string $joins = null,
?string $where = null,
?string $order = null,
?int $offset = null,
?int $limit = null,
bool $distinct = false,
?string $groupBy = null,
?string $having = null,
?array $indexKeyList = null,
?string $tailPart = null,
?string $withRecursivePart = null,
): string {
$sql = "SELECT";
if ($withRecursivePart !== null) {
$sql = "$withRecursivePart " . $sql;
}
if (!empty($distinct) && empty($groupBy)) {
$sql .= " DISTINCT";
}
$sql .= " $select";
if ($from) {
$sql .= " FROM $from";
}
if ($alias) {
$sql .= " AS " . $this->quoteIdentifier($alias);
}
if ($this->indexHints && !empty($indexKeyList)) {
foreach ($indexKeyList as $index) {
$sql .= " USE INDEX (" . $this->quoteIdentifier($this->sanitizeIndexName($index)) . ")";
}
}
if (!empty($joins)) {
$sql .= " $joins";
}
if ($where !== null && $where !== '') {
$sql .= " WHERE $where";
}
if (!empty($groupBy)) {
$sql .= " GROUP BY $groupBy";
}
if ($having !== null && $having !== '') {
$sql .= " HAVING $having";
}
if (!empty($order)) {
$sql .= " ORDER BY $order";
}
if (is_null($offset) && !is_null($limit)) {
$offset = 0;
}
$sql = $this->limit($sql, $offset, $limit);
if ($tailPart) {
$sql .= " " . $tailPart;
}
return $sql;
}
protected function composeDeleteQuery(
string $table,
?string $alias,
string $where,
?string $joins,
?string $order,
?int $limit
): string {
$sql = "DELETE ";
if ($alias) {
$sql .= $this->quoteIdentifier($alias) . " ";
}
$sql .= "FROM " . $this->quoteIdentifier($table);
if ($alias) {
$sql .= " AS " . $this->quoteIdentifier($alias);
}
if ($joins) {
$sql .= " $joins";
}
if ($where) {
$sql .= " WHERE $where";
}
if ($order) {
$sql .= " ORDER BY $order";
}
if ($limit !== null) {
$sql = $this->limit($sql, null, $limit);
}
return $sql;
}
protected function composeUpdateQuery(
string $table,
string $set,
string $where,
?string $joins,
?string $order,
?int $limit
): string {
$sql = "UPDATE " . $this->quoteIdentifier($table);
if ($joins) {
$sql .= " $joins";
}
$sql .= " SET $set";
if ($where) {
$sql .= " WHERE $where";
}
if ($order) {
$sql .= " ORDER BY $order";
}
if ($limit !== null) {
$sql = $this->limit($sql, null, $limit);
}
return $sql;
}
protected function composeInsertQuery(
string $table,
string $columns,
string $values,
?string $update = null
): string {
$sql = "INSERT INTO " . $this->quoteIdentifier($table) . " ($columns) $values";
if ($update) {
$sql .= " ON DUPLICATE KEY UPDATE " . $update;
}
return $sql;
}
/**
* @param array<string, mixed> $values
* @param array<string, mixed> $params
*/
protected function getSetPart(Entity $entity, array $values, array $params): string
{
if (!count($values)) {
throw new RuntimeException("ORM Query: No SET values for update query.");
}
$list = [];
foreach ($values as $attribute => $value) {
$isNotValue = false;
if (str_ends_with($attribute, ':')) {
$attribute = substr($attribute, 0, -1);
$isNotValue = true;
}
if (strpos($attribute, '.') > 0) {
[$alias, $attribute] = explode('.', $attribute);
/** @noinspection PhpDeprecationInspection */
$alias = $this->sanitize($alias);
/** @noinspection PhpDeprecationInspection */
$column = $this->toDb($this->sanitize($attribute));
$left = $this->quoteColumn("$alias.$column");
} else {
$table = $this->toDb($entity->getEntityType());
/** @noinspection PhpDeprecationInspection */
$column = $this->toDb($this->sanitize($attribute));
$left = $this->quoteColumn("$table.$column");
}
$right = $isNotValue ?
$this->convertComplexExpression($entity, $value, false, $params) :
$this->quote($value);
$list[] = $left . " = " . $right;
}
return implode(', ', $list);
}
/**
* @param string[] $columnList
*/
protected function getInsertColumnsPart(array $columnList): string
{
$list = [];
foreach ($columnList as $column) {
/** @noinspection PhpDeprecationInspection */
$list[] = $this->quoteIdentifier(
$this->toDb(
$this->sanitize($column)
)
);
}
return implode(', ', $list);
}
/**
* @param string[] $columnList
* @param array<string, mixed> $values
*/
protected function getInsertValuesItemPart(array $columnList, array $values): string
{
$list = [];
foreach ($columnList as $column) {
$list[] = $this->quote($values[$column]);
}
return implode(', ', $list);
}
/**
* @param array<string, mixed> $values
*/
protected function getInsertUpdatePart(array $values): string
{
$list = [];
foreach ($values as $column => $value) {
/** @noinspection PhpDeprecationInspection */
$list[] = $this->quoteIdentifier(
$this->toDb(
$this->sanitize($column)
)
) . " = " . $this->quote($value);
}
return implode(', ', $list);
}
/**
* @return mixed
*/
protected function getAttributeParam(Entity $entity, string $attribute, string $param)
{
if ($entity instanceof BaseEntity) {
return $entity->getAttributeParam($attribute, $param);
}
$entityDefs = $this->metadata
->getDefs()
->getEntity($entity->getEntityType());
if (!$entityDefs->hasAttribute($attribute)) {
return null;
}
return $entityDefs->getAttribute($attribute)->getParam($param);
}
/**
* @return mixed
*/
protected function getRelationParam(Entity $entity, string $relation, string $param)
{
if ($entity instanceof BaseEntity) {
return $entity->getRelationParam($relation, $param);
}
$entityDefs = $this->metadata
->getDefs()
->getEntity($entity->getEntityType());
if (!$entityDefs->hasRelation($relation)) {
return null;
}
return $entityDefs->getRelation($relation)->getParam($param);
}
/**
* Add a LIMIT part to an SQL query.
*/
abstract protected function limit(string $sql, ?int $offset = null, ?int $limit = null): string;
/**
* @param array<string, mixed> $params
*/
private function getWithRecursivePart(array $params): ?string
{
$items = $params['withRecursive'] ?? null;
if (!is_array($items)) {
return null;
}
$bodyParts = [];
foreach ($items as $item) {
if (!is_array($item) || count($item) < 2) {
continue;
}
$query = $item[0];
$name = $item[1];
if (!$query instanceof Union || !is_string($name)) {
continue;
}
$bodyParts[] = $this->getWithRecursivePartItem($query, $name);
}
if ($bodyParts === []) {
return null;
}
return "WITH RECURSIVE " . implode(', ', $bodyParts);
}
private function getWithRecursivePartItem(Union $query, string $name): string
{
$unionPart = $this->composeUnion($query);
$namePart = $this->quoteIdentifier($this->toDb($name));
return "$namePart AS ($unionPart)";
}
}