Files
espocrm/application/Espo/ORM/QueryComposer/BaseQueryComposer.php
2023-02-12 15:13:43 +02:00

3672 lines
103 KiB
PHP

<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://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 General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\ORM\QueryComposer;
use Espo\ORM\Entity;
use Espo\ORM\EntityFactory;
use Espo\ORM\BaseEntity;
use Espo\ORM\Metadata;
use Espo\ORM\Mapper\Helper;
use Espo\ORM\Query\Query as Query;
use Espo\ORM\Query\SelectingQuery;
use Espo\ORM\Query\Select as SelectQuery;
use Espo\ORM\Query\Update as UpdateQuery;
use Espo\ORM\Query\Insert as InsertQuery;
use Espo\ORM\Query\Delete as DeleteQuery;
use Espo\ORM\Query\Union as UnionQuery;
use Espo\ORM\QueryComposer\Part\FunctionConverterFactory;
use PDO;
use RuntimeException;
use LogicException;
/**
* 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',
'aggregation',
'aggregationBy',
'groupBy',
'havingClause',
'customHaving',
'skipTextColumns',
'maxTextColumnsLength',
'useIndex',
'withDeleted',
'set',
'from',
'fromAlias',
'fromQuery',
'forUpdate',
'forShare',
];
/** @var string[] */
protected const SQL_OPERATORS = [
'OR',
'AND',
];
protected const EXISTS_OPERATOR = 'EXISTS';
/** @var array<string, string> */
protected array $comparisonOperators = [
'!=s' => 'NOT IN',
'=s' => 'IN',
'!=' => '<>',
'!*' => 'NOT LIKE',
'*' => 'LIKE',
'>=' => '>=',
'<=' => '<=',
'>' => '>',
'<' => '<',
'=' => '=',
];
/** @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 bool $indexHints = true;
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
) {
$this->entityFactory = $entityFactory;
$this->pdo = $pdo;
$this->metadata = $metadata;
$this->functionConverterFactory = $functionConverterFactory;
$this->helper = new Helper($metadata);
}
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 Use wrapper or methods directly.
*/
public function compose(Query $query): string
{
$wrapper = new QueryComposerWrapper($this);
return $wrapper->compose($query);
}
public function composeCreateSavepoint(string $savepointName): string
{
return 'SAVEPOINT ' . $this->sanitize($savepointName);
}
public function composeReleaseSavepoint(string $savepointName): string
{
return 'RELEASE SAVEPOINT ' . $this->sanitize($savepointName);
}
public function composeRollbackToSavepoint(string $savepointName): string
{
return 'ROLLBACK TO SAVEPOINT ' . $this->sanitize($savepointName);
}
protected function composeSelecting(SelectingQuery $query): string
{
if ($query instanceof SelectQuery) {
return $this->composeSelect($query);
}
if ($query instanceof UnionQuery) {
return $this->composeUnion($query);
}
throw new RuntimeException("Unknown query type.");
}
public function composeSelect(SelectQuery $query): string
{
$params = $query->getRaw();
return $this->createSelectQueryInternal($params);
}
public function composeUpdate(UpdateQuery $query): string
{
$params = $query->getRaw();
return $this->createUpdateQuery($params);
}
public function composeDelete(DeleteQuery $query): string
{
$params = $query->getRaw();
return $this->createDeleteQuery($params);
}
public function composeInsert(InsertQuery $query): string
{
$params = $query->getRaw();
return $this->createInsertQuery($params);
}
public function composeUnion(UnionQuery $query): string
{
$params = $query->getRaw();
return $this->createUnionQuery($params);
}
/**
* @deprecated
* @todo Remove in v7.3.
* @param array<string, mixed>|null $params
*/
public function createSelectQuery(string $entityType, ?array $params = null): string
{
$params = $params ?? [];
$params['from'] = $entityType;
return $this->composeSelect(SelectQuery::fromRaw($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) {
$aliasPart = $this->sanitize($alias);
}
$sql = $this->composeDeleteQuery(
$this->toDb($entityType),
$aliasPart,
$wherePart,
$joinsPart,
$orderPart,
$params['limit']
);
return $sql;
}
/**
* @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);
$sql = $this->composeUpdateQuery(
$this->toDb($entityType),
$setPart,
$wherePart,
$joinsPart,
$orderPart,
$params['limit']
);
return $sql;
}
/**
* @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
*/
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 = SelectQuery::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 {
$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['aggregation'])) {
throw new RuntimeException("ORM Query: Param 'aggregation' is not allowed for '{$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;
if ($entityType === null && !$fromQuery) {
return $this->createSelectQueryNoFrom($params);
}
$entity = $this->getSeed($entityType);
$isAggregation = (bool) ($params['aggregation'] ?? null);
$whereClause = $params['whereClause'] ?? [];
$havingClause = $params['havingClause'] ?? [];
if (!$params['withDeleted'] && $entity->hasAttribute('deleted')) {
$whereClause = $whereClause + ['deleted' => false];
}
$selectPart = null;
$orderPart = null;
$havingPart = null;
$tailPart = null;
$wherePart = $this->getWherePart($entity, $whereClause, 'AND', $params);
if (!empty($havingClause)) {
$havingPart = $this->getWherePart($entity, $havingClause, 'AND', $params);
}
if (!$isAggregation) {
$orderPart = $this->getOrderPart($entity, $params['orderBy'], $params['order'], $params);
$selectPart = $this->getSelectPart($entity, $params);
$additionalSelectPart = $this->getAdditionalSelect($entity, $params);
if ($additionalSelectPart) {
$selectPart .= $additionalSelectPart;
}
$tailPart = $this->getSelectTailPart($params);
}
if ($isAggregation) {
$aggregationDistinct = false;
if ($params['distinct'] && $params['aggregation'] == 'COUNT') {
$aggregationDistinct = true;
}
$params['select'] = [];
$selectPart = $this->getAggregationSelectPart(
$entity,
$params['aggregation'],
$params['aggregationBy'],
$aggregationDistinct,
$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'];
}
$joinsPart = $this->getJoinsPart($entity, $params, !$isAggregation);
$groupByPart = $this->getGroupByPart($entity, $params);
$indexKeyList = [];
if ($entityType) {
$indexKeyList = $this->getIndexKeyList($entityType, $params);
}
$fromAlias = $params['fromAlias'] ?? null;
if ($fromAlias) {
$fromAlias = $this->sanitize($fromAlias);
}
$fromPart = null;
if ($entityType) {
$fromPart = $this->quoteIdentifier(
$this->toDb($entityType)
);
}
if ($fromQuery) {
$fromPart = '(' . $this->composeSelecting($fromQuery) . ')';
}
/** @var string $selectPart */
/** @var string $fromAlias */
if ($isAggregation) {
$sql = $this->composeSelectQuery(
$fromPart,
$selectPart,
$fromAlias,
$joinsPart,
$wherePart,
null,
null,
null,
false,
$groupByPart,
$havingPart,
$indexKeyList
);
if ($params['aggregation'] === 'COUNT' && $groupByPart && $havingPart) {
return $this->wrapCountSql($sql);
}
return $sql;
}
$sql = $this->composeSelectQuery(
$fromPart,
$selectPart,
$fromAlias,
$joinsPart,
$wherePart,
$orderPart,
$params['offset'],
$params['limit'],
$params['distinct'],
$groupByPart,
$havingPart,
$indexKeyList,
$tailPart
);
return $sql;
}
protected function wrapCountSql(string $sql): string
{
return
"SELECT COUNT(*) AS " . $this->quoteIdentifier('value') . " ".
"FROM ({$sql}) AS " . $this->quoteIdentifier('countAlias');
}
/**
* @param array<string, mixed> $params
*/
protected function createSelectQueryNoFrom(array $params): string
{
$selectPart = $this->getSelectPart(null, $params);
$sql = $this->composeSelectQuery(
null,
$selectPart
);
return $sql;
}
/**
* @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, ['indexes', $indexName, 'key']);
if ($indexKey) {
$indexKeyList[] = $indexKey;
}
}
return $indexKeyList;
}
/**
* @param array<string, mixed> $params
*/
protected function getJoinsPart(Entity $entity, array $params, bool $includeBelongsTo = false): string
{
$joinsPart = '';
if ($includeBelongsTo) {
$joinsPart = $this->getBelongsToJoinsPart(
$entity,
$params['select'],
array_merge($params['joins'], $params['leftJoins']),
$params
);
}
if (!empty($params['joins']) && is_array($params['joins'])) {
// @todo array unique
$joinsItemPart = $this->getJoinsTypePart(
$entity,
$params['joins'],
false,
$params['joinConditions'],
$params
);
if (!empty($joinsItemPart)) {
if (!empty($joinsPart)) {
$joinsPart .= ' ';
}
$joinsPart .= $joinsItemPart;
}
}
if (!empty($params['leftJoins']) && is_array($params['leftJoins'])) {
// @todo array unique
$joinsItemPart = $this->getJoinsTypePart(
$entity,
$params['leftJoins'],
true,
$params['joinConditions'],
$params
);
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 '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) . ')';
}
if ($distinct) {
$fromAlias = $this->getFromAlias($params, $entityType);
$idPart = $fromAlias . ".id";
switch ($function) {
case 'COUNT':
return $function . "({$part}) * COUNT(DISTINCT {$idPart}) / COUNT({$idPart})";
}
}
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);
if ($offset < 0) {
$offsetString = '-' . $offsetString;
} else {
$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, ':')) {
/** @var int $delimiterPosition */
$delimiterPosition = 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)) {
$function = strtoupper($this->sanitize($function));
}
$argumentPartList = null;
if ($function) {
$arguments = $attribute;
$argumentList = Util::parseArgumentListFromFunctionContent($arguments);
$argumentPartList = [];
foreach ($argumentList as $argument) {
$argumentPartList[] = $this->getFunctionArgumentPart($entity, $argument, $distinct, $params);
}
$part = implode(', ', $argumentPartList);
}
else {
$part = $this->getFunctionArgumentPart($entity, $attribute, $distinct, $params);
}
if ($function) {
/** @var string[] $argumentPartList */
$part = $this->getFunctionPart(
$function,
$part,
$params,
$entityType,
$distinct,
$argumentPartList
);
}
return $part;
}
/**
* @return string[]
* @deprecated
*/
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)) {
$string = substr($argument, 1, -1);
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, '.')) {
list($relName, $attribute) = explode('.', $argument);
}
if (!empty($relName)) {
$relName = $this->sanitize($relName);
}
if (!empty($attribute)) {
$attribute = $this->sanitize($attribute);
}
if ($attribute !== '') {
$part = $this->toDb($attribute);
} else {
$part = '';
}
if ($relName) {
$part = $this->quoteColumn($relName . '.' . $part);
$foreignEntityType = $this->getRelationParam($entity, $relName, '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 ($this->getAttributeParam($entity, $attribute, 'select')) {
return $this->getAttributeSql($entity, $attribute, 'select', $params);
}
if ($part !== '') {
$part = $this->getFromAlias($params, $entityType) . '.' . $part;
$part = $this->quoteColumn($part);
}
return $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) {
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>|null $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');
$part = str_replace('{direction}', $order, $part);
return $part;
}
if (!empty($defs['sql'])) {
// @deprecated
$part = $defs['sql'];
$part = str_replace('{direction}', $order, $part);
return $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;
}
$part = $this->getFromAlias($params, $entity->getEntityType()) . '.' .
$this->toDb($this->sanitize($attribute));
$part = $this->quoteColumn($part);
$part .= ' ' . $order;
return $part;
}
/**
* @param array<string, mixed>|null $params
*/
protected function getAttributeSql(
Entity $entity,
string $attribute,
string $type,
?array &$params = null,
?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());
$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) {
$jAlias = $this->obtainJoinAlias($j);
if ($alias) {
$jAlias = str_replace('{alias}', $alias, $jAlias);
}
if (isset($j[1])) {
$j[1] = $jAlias;
}
foreach ($params['leftJoins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
}
if ($alias) {
if (count($j) >= 3) {
$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;
}
}
$params['leftJoins'][] = $j;
}
}
if (!empty($defs['joins'])) {
foreach ($defs['joins'] as $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) {
if (count($j) >= 3) {
$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;
}
}
$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];
}
$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;
continue;
}
}
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 && $itemList[0] === '*' && $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];
$alias = $this->sanitizeSelectAlias($item[1]);
if ($expression === '' || $alias === '') {
throw new RuntimeException("Bad select expression.");
}
$selectPartItemList[] = "{$expression} AS " . $this->quoteIdentifier($alias);
}
$selectPart = implode(', ', $selectPartItemList);
return $selectPart;
}
/**
* @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_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) === 2) {
$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, 'notStorable')) {
return null;
}
/** @var string $part */
$part = $this->getAttributePath($entity, $attribute0, $params);
return [$part, $alias];
}
if (!is_string($attribute)) {
throw new RuntimeException();
}
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, 'notStorable') && $attributeType !== Entity::FOREIGN) {
return null;
}
/** @var string $fieldPath */
$fieldPath = $this->getAttributePath($entity, $attribute, $params);
if ($attributeType === Entity::TEXT && $maxTextColumnsLength !== null) {
$fieldPath = 'LEFT(' . $fieldPath . ', '. strval($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, 'dependeeAttributeList') ?? []
);
}
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'];
$alias = !$alias ?
$this->getAlias($entity, $relationName) :
$this->sanitizeSelectAlias($alias);
if (!$alias) {
return null;
}
$foreignEntityType = $this->getRelationParam($entity, $relationName, '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));
return
"JOIN " . $this->quoteIdentifier($table) . " AS " . $this->quoteIdentifier($alias) . " ON ".
"{$leftColumnPart} = {$rightColumnPart}";
}
/**
* @param array<string, mixed> $params
*/
protected function getSelectTailPart(array $params): ?string
{
$forShare = $params['forShare'] ?? null;
$forUpdate = $params['forUpdate'] ?? null;
if ($forShare) {
return "FOR SHARE";
}
if ($forUpdate) {
return "FOR UPDATE";
}
return null;
}
/**
* @param string[]|array<string[]> $select
* @param string[] $skipList
* @param array<string, mixed> $params
*/
protected function getBelongsToJoinsPart(Entity $entity, ?array $select, array $skipList, 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) == 'foreign' &&
$this->getAttributeParam($entity, $field, 'relation')
) {
$relationsToJoin[] = $this->getAttributeParam($entity, $field, 'relation');
}
else if (
$this->getAttributeParam($entity, $field, 'fieldType') == 'linkOne' &&
$this->getAttributeParam($entity, $field, 'relation')
) {
$relationsToJoin[] = $this->getAttributeParam($entity, $field, '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, 'noJoin')) {
continue;
}
if (in_array($relationName, $skipList)) {
continue;
}
foreach ($skipList 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<string[]|string> $select
*/
protected static function isSelectAll(array $select): bool
{
if (!count($select)) {
return true;
}
return $select[0] === '*';
}
/**
* @param array<string, mixed>|null $params
* @param mixed $orderBy
* @param mixed $order
*/
protected function getOrderExpressionPart(
Entity $entity,
$orderBy = null,
$order = null,
?array &$params = null,
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>|null $params
* @param mixed $orderBy
* @param mixed $order
*/
protected function getOrderPart(Entity $entity, $orderBy = null, $order = null, &$params = null): ?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);
}
/**
* @param array<string, mixed> $params
*/
protected function getAggregationSelectPart(
Entity $entity,
string $aggregation,
string $aggregationBy,
bool $distinct,
array $params
): ?string {
if (!$entity->hasAttribute($aggregationBy)) {
return null;
}
$aggregationPart = $this->sanitize(strtoupper($aggregation));
$distinctPart = $distinct ? 'DISTINCT ' : '';
$fromAlias = $this->getFromAlias($params, $entity->getEntityType());
$columnPart = "{$fromAlias}." . $this->toDb($this->sanitize($aggregationBy));
$columnPart = $this->quoteColumn($columnPart);
return "{$aggregationPart}({$distinctPart}{$columnPart}) AS " . $this->quoteIdentifier('value');
}
/**
* Quote a value (if needed).
* @deprecated
* @todo Make protected in 6.5.
* @param mixed $value
*/
public 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, 'notStorable') && $attributeType !== 'foreign') {
return null;
}
switch ($attributeType) {
case Entity::FOREIGN:
$relationName = $this->getAttributeParam($entity, $attribute, 'relation');
if (!$relationName) {
return null;
}
$foreign = $this->getAttributeParam($entity, $attribute, '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);
$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);
}
/**
* @param array<string, mixed> $params
*/
protected function getWherePartItem(
Entity $entity,
mixed $leftKey,
mixed $value,
array &$params,
int $level,
bool $noCustomWhere = false
): ?string {
if (is_int($leftKey) && is_string($value)) {
return $this->convertComplexExpression($entity, $value, false, $params);
}
$field = $leftKey;
if (is_int($field)) {
$field = 'AND';
}
if ($leftKey === 'NOT') {
$field = 'AND';
}
if (in_array($field, self::SQL_OPERATORS)) {
$internalPart = $this->getWherePart($entity, $value, $field, $params, $level + 1);
if (!$internalPart && $internalPart !== '0') {
return null;
}
if ($leftKey === 'NOT') {
return "NOT (" . $internalPart . ")";
}
return "(" . $internalPart . ")";
}
if ($field === self::EXISTS_OPERATOR) {
if (!is_array($value)) {
throw new RuntimeException("Bad EXISTS usage in where-clause.");
}
$subQueryPart = $this->createSelectQueryInternal($value);
return "EXISTS ({$subQueryPart})";
}
$isComplex = false;
$isNotValue = false;
if (str_ends_with($field, ':')) {
$field = substr($field, 0, strlen($field) - 1);
$isNotValue = true;
}
$operator = '=';
$operatorOrm = '=';
if (!preg_match('/^[a-z0-9]+$/i', $field)) {
foreach ($this->comparisonOperators as $op => $opDb) {
if (str_ends_with($field, $op)) {
$field = trim(substr($field, 0, -strlen($op)));
$operatorOrm = $op;
$operator = $opDb;
break;
}
}
}
$leftPart = null;
if (Util::isComplexExpression($field)) {
$leftPart = $this->convertComplexExpression($entity, $field, false, $params);
$isComplex = true;
}
if (!$isComplex) {
if (!$entity->hasAttribute($field)) {
return $this->quote(false);
}
$operatorKey = $this->getWhereOperatorKey(
$operator,
$operatorOrm,
$value,
$entity->getAttributeType($field)
);
if (
!$noCustomWhere &&
$this->getAttributeParam($entity, $field, 'where') &&
isset($this->getAttributeParam($entity, $field, 'where')[$operatorKey])
) {
$whereDefs = $this->getAttributeParam($entity, $field, 'where')[$operatorKey];
return $this->getWherePartItemCustom($entity, $value, $whereDefs, $params, $level);
}
$leftPart = $this->getWherePartItemAttributeLeftPart($entity, $field, $params);
}
if ($leftPart === null) {
return $this->quote(false);
}
if ($operatorOrm === '=s' || $operatorOrm === '!=s') {
if (!is_array($value)) {
return $this->quote(false);
}
$subQuerySelectParams = !empty($value['selectParams']) ?
$value['selectParams'] :
$value;
if (
!isset($subQuerySelectParams['from']) &&
!isset($subQuerySelectParams['fromQuery'])
) {
// 'entityType' is for backward compatibility.
$subQuerySelectParams['from'] = $value['entityType'] ?? $entity->getEntityType();
}
if (!empty($value['withDeleted'])) {
$subQuerySelectParams['withDeleted'] = true;
}
$subSql = $this->createSelectQueryInternal($subQuerySelectParams);
return "{$leftPart} {$operator} ({$subSql})";
}
if (is_array($value)) {
$valuePartList = $value;
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($value)) {
return $leftPart;
}
$expressionSql = $this->convertComplexExpression($entity, $value, false, $params);
return "{$leftPart} {$operator} {$expressionSql}";
}
if (is_null($value)) {
if ($operator === '=') {
return "{$leftPart} IS NULL";
}
if ($operator === '<>') {
return "{$leftPart} IS NOT NULL";
}
return $this->quote(false);
}
$valuePart = $this->quote($value);
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, 'relation');
$foreign = $this->getAttributeParam($entity, $attribute, '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());
$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);
foreach ($params['leftJoins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
}
$params['leftJoins'][] = $j;
}
foreach ($joins as $j) {
$jAlias = $this->obtainJoinAlias($j);
foreach ($params['joins'] as $jE) {
$jEAlias = $this->obtainJoinAlias($jE);
if ($jEAlias === $jAlias) {
continue 2;
}
}
$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)) {
$right = str_replace('{value}', (string) $value, $right);
}
else if (is_array($right)) {
$right = $this->applyValueToCustomWhereClause($right, $value);
}
$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 6.5.
* @deprecated
*/
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 6.5.
* @deprecated
*/
public function sanitizeSelectAlias(string $string): string
{
$string = preg_replace('/[^A-Za-z\r\n0-9_:\'" .,\-\(\)]+/', '', $string) ?? '';
if (strlen($string) > 256) {
$string = substr($string, 0, 256);
}
return $string;
}
protected function sanitizeIndexName(string $string): string
{
return preg_replace('/[^A-Za-z0-9_]+/', '', $string) ?? '';
}
/**
* @param array<string, mixed> $params
* @param array<mixed, mixed> $joinConditions
* @param array<string, mixed[]> $joins
*/
protected function getJoinsTypePart(
Entity $entity,
array $joins,
bool $isLeft,
$joinConditions,
array $params
): string {
$joinSqlList = [];
foreach ($joins as $item) {
$itemConditions = [];
$itemParams = [];
if (is_array($item)) {
$relationName = $item[0];
if (count($item) > 1) {
$alias = $item[1] ?? $relationName;
if (count($item) > 2) {
$itemConditions = $item[2] ?? [];
}
if (count($item) > 3) {
$itemParams = $item[3] ?? [];
}
}
else {
$alias = $relationName;
}
}
else {
$relationName = $item;
$alias = $relationName;
}
$conditions = [];
if (!empty($joinConditions[$alias])) {
$conditions = $joinConditions[$alias];
}
foreach ($itemConditions as $left => $right) {
$conditions[$left] = $right;
}
$sql = $this->getJoinItemPart(
$entity,
$relationName,
$isLeft,
$conditions,
$alias,
$itemParams,
$params
);
if ($sql) {
$joinSqlList[] = $sql;
}
}
return implode(' ', $joinSqlList);
}
/**
* @param array<string, mixed> $params
* @param string $alias
* @param mixed $left
* @param mixed $right
*/
protected function buildJoinConditionStatement(
Entity $entity,
string $alias,
$left,
$right,
array $params,
bool $noLeftAlias = false
): string {
$sql = '';
if (is_array($right) && (is_int($left) || in_array($left, ['AND', 'OR']))) {
$logicalOperator = 'AND';
if ($left === 'OR') {
$logicalOperator = 'OR';
}
$sqlList = [];
foreach ($right as $k => $v) {
$sqlList[] = $this->buildJoinConditionStatement($entity, $alias, $k, $v, $params, $noLeftAlias);
}
$sql = implode(' ' . $logicalOperator . ' ', $sqlList);
if (count($sqlList) > 1) {
$sql = '(' . $sql . ')';
}
return $sql;
}
$operator = '=';
$isNotValue = false;
$isComplex = false;
if (str_ends_with($left, ':')) {
$left = substr($left, 0, strlen($left) - 1);
$isNotValue = true;
}
if (!preg_match('/^[a-z0-9]+$/i', $left)) {
foreach ($this->comparisonOperators as $op => $opDb) {
if (str_ends_with($left, $op)) {
$left = trim(substr($left, 0, -strlen($op)));
$operator = $opDb;
break;
}
}
}
if (Util::isComplexExpression($left)) {
$isComplex = true;
$stub = [];
$sql .= $this->convertComplexExpression($entity, $left, false, $stub);
}
if (!$isComplex) {
if (strpos($left, '.') > 0) {
list($leftAlias, $attribute) = explode('.', $left);
$leftAlias = $this->sanitize($leftAlias);
$column = $this->toDb($this->sanitize($attribute));
}
else {
$column = $this->toDb($this->sanitize($left));
$leftAlias = $noLeftAlias ?
$this->getFromAlias($params, $entity->getEntityType()) :
$this->sanitize($alias);
}
$sql .= $this->quoteColumn("{$leftAlias}.{$column}");
}
if (is_array($right)) {
$arr = [];
foreach ($right as $item) {
$arr[] = $this->quote($item);
}
$operator = $operator === '<>' ? 'NOT IN' : 'IN';
if (count($arr)) {
$sql .= " " . $operator . " (" . implode(', ', $arr) . ")";
return $sql;
}
if ($operator === 'IN') {
$sql .= " IS NULL";
return $sql;
}
$sql .= " IS NOT NULL";
return $sql;
}
$value = $right;
if (is_null($value)) {
if ($operator === '=') {
$sql .= " IS NULL";
} else if ($operator === '<>') {
$sql .= " IS NOT NULL";
}
return $sql;
}
if ($isNotValue) {
$rightPart = $this->convertComplexExpression($entity, $value, false, $params);
$sql .= " " . $operator . " " . $rightPart;
return $sql;
}
$sql .= " " . $operator . " " . $this->quote($value);
return $sql;
}
/**
* @param array<string, mixed> $params
* @param array<string, mixed> $joinParams
* @param array<mixed, mixed> $conditions
*/
protected function getJoinItemPart(
Entity $entity,
string $name,
bool $isLeft = false,
array $conditions = [],
?string $alias = null,
array $joinParams = [],
array $params = []
): string {
$prefix = ($isLeft) ? 'LEFT ' : '';
if (!$entity->hasRelation($name)) {
$alias = !$alias ?
$this->sanitize($name) :
$this->sanitizeSelectAlias($alias);
$table = $this->toDb($this->sanitize($name));
$sql = $prefix . "JOIN " . $this->quoteIdentifier($table) . " AS " . $this->quoteIdentifier($alias);
if (empty($conditions)) {
return $sql;
}
$sql .= " ON";
$joinSqlList = [];
foreach ($conditions as $left => $right) {
$joinSqlList[] = $this->buildJoinConditionStatement(
$entity,
$alias,
$left,
$right,
$params,
$joinParams['noLeftAlias'] ?? false,
);
}
$sql .= " " . implode(" AND ", $joinSqlList);
return $sql;
}
$relationName = $name;
$keySet = $this->helper->getRelationKeys($entity, $relationName);
if (!$alias) {
$alias = $relationName;
}
$alias = $this->sanitize($alias);
$relationConditions = $this->getRelationParam($entity, $relationName, 'conditions');
$foreignEntityType = $this->getRelationParam($entity, $relationName, '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, 'relationName')
);
$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(),
['relations', $relationName, 'indexes', $indexName, '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 =
"{$prefix}JOIN ".$this->quoteIdentifier($relTable)." AS " .
$this->quoteIdentifier($midAlias) . "{$indexPart} " .
"ON {$leftKeyColumn} = {$middleKeyColumn}" .
" AND " .
"{$middleDeletedColumn} = " . $this->quote(false);
$joinSqlList = [];
foreach ($conditions as $left => $right) {
$joinSqlList[] = $this->buildJoinConditionStatement(
$entity,
$midAlias,
$left,
$right,
$params
);
}
if (count($joinSqlList)) {
$sql .= " AND " . implode(" AND ", $joinSqlList);
}
if (!$onlyMiddle) {
$rightKeyColumn = $this->quoteColumn("{$alias}." . $this->toDb($foreignKey));
$middleDistantKeyColumn = $this->quoteColumn("{$midAlias}." . $this->toDb($distantKey));
$rightDeletedColumn = $this->quoteColumn("{$alias}.deleted");
$sql .= " {$prefix}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('id'));
$rightIdColumn = $this->quoteColumn("{$alias}." . $this->toDb($foreignKey));
$leftDeletedColumn = $this->quoteColumn("{$alias}.deleted");
$sql =
"{$prefix}JOIN " . $this->quoteIdentifier($distantTable) . " AS "
. $this->quoteIdentifier($alias) . " ON "
. "{$leftIdColumn} = {$rightIdColumn} AND "
. "{$leftDeletedColumn} = " . $this->quote(false);
$joinSqlList = [];
foreach ($conditions as $left => $right) {
$joinSqlList[] = $this->buildJoinConditionStatement($entity, $alias, $left, $right, $params);
}
if (count($joinSqlList)) {
$sql .= " AND " . implode(" AND ", $joinSqlList);
}
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('id'));
$rightIdColumn = $this->quoteColumn("{$alias}." . $this->toDb($foreignKey));
$leftTypeColumn = $this->quoteColumn("{$alias}." . $this->toDb($foreignType));
$leftDeletedColumn = $this->quoteColumn("{$alias}.deleted");
$sql =
"{$prefix}JOIN " . $this->quoteIdentifier($distantTable)
. " AS "
. $this->quoteIdentifier($alias) . " ON "
. "{$leftIdColumn} = {$rightIdColumn} AND "
. "{$leftTypeColumn} = " . $this->pdo->quote($entity->getEntityType()) . " AND "
. "{$leftDeletedColumn} = " . $this->quote(false);
$joinSqlList = [];
foreach ($conditions as $left => $right) {
$joinSqlList[] = $this->buildJoinConditionStatement($entity, $alias, $left, $right, $params);
}
if (count($joinSqlList)) {
$sql .= " AND " . implode(" AND ", $joinSqlList);
}
return $sql;
case Entity::BELONGS_TO:
return $prefix . $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 {
$sql = "SELECT";
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);
$alias = $this->sanitize($alias);
$column = $this->toDb($this->sanitize($attribute));
$left = $this->quoteColumn("{$alias}.{$column}");
}
else {
$table = $this->toDb($entity->getEntityType());
$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) {
$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) {
$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 a SQL query.
*/
abstract protected function limit(string $sql, ?int $offset = null, ?int $limit = null): string;
}