getItemList() as $item) { if ( $item->getType() === self::WHERE_TYPE_AND && $item->getAttribute() === self::ATTR_HAVING ) { foreach ($item->getItemList() as $subItem) { $havingItemList[] = $subItem; } continue; } $whereItemList[] = $item; } $whereItem = WhereItemBuilder::create() ->setType(self::WHERE_TYPE_AND) ->setItemList($whereItemList) ->build(); $havingItem = WhereItemBuilder::create() ->setType(self::WHERE_TYPE_AND) ->setItemList($havingItemList) ->build(); return [$whereItem, $havingItem]; } /** * @throws Forbidden */ public function handleOrderByForList(string $orderBy, string $order, SelectBuilder $queryBuilder): void { $entityType = $queryBuilder->build()->getFrom(); if (!$entityType) { throw new LogicException("No from."); } $entityDefs = $this->entityManager->getDefs()->getEntity($entityType); $fieldType = $entityDefs->hasField($orderBy) ? $entityDefs->getField($orderBy)->getType() : null; if ( in_array($fieldType, ['link', 'file', 'image']) && !$queryBuilder->hasLeftJoinAlias($orderBy) ) { $queryBuilder->leftJoin($orderBy); } if (str_contains($orderBy, '_')) { if (str_contains($orderBy, ':')) { throw new Forbidden("Functions are not allowed in orderBy."); } $orderBy = $this->getRealForeignOrderColumn($entityType, $orderBy); $this->addSelect($orderBy, $queryBuilder); /** @var 'ASC'|'DESC' $order */ $queryBuilder ->order([]) ->order($orderBy, $order) ->order('id', $order); return; } foreach ($this->fieldUtil->getAttributeList($entityType, $orderBy) as $attribute) { if (!$entityDefs->hasAttribute($attribute)) { continue; } $this->addSelect($attribute, $queryBuilder); } } private function getRealForeignOrderColumn(string $entityType, string $item): string { $item = str_replace('_', '.', $item); $data = $this->gridHelper->getDataFromColumnName($entityType, $item); if (!$data->entityType) { throw new RuntimeException("Bad foreign order by '$item'."); } if (in_array($data->fieldType, ['link', 'linkParent', 'image', 'file'])) { return $item . 'Id'; } return $item; } /** * @param string[] $groupBy */ public function handleGroupBy(array $groupBy, SelectBuilder $queryBuilder): void { $entityType = $queryBuilder->build()->getFrom(); if (!$entityType) { throw new LogicException("No from."); } foreach ($groupBy as $item) { $this->handleGroupByItem($item, $entityType, $queryBuilder); } } private function handleGroupByItem(string $item, string $entityType, SelectBuilder $queryBuilder): void { $alias = $this->gridUtil->sanitizeSelectAlias($item); $function = null; $argument = $item; if (str_contains($item, ':')) { [$function, $argument] = explode(':', $item); } if (str_contains($item, '(') && str_contains($item, ':')) { $this->handleLeftJoins($item, $entityType, $queryBuilder, true); $queryBuilder ->select($item, $alias) ->group($item); return; } if ($function === 'YEAR_FISCAL') { $fiscalYearShift = $this->config->get('fiscalYearShift', 0); $function = $fiscalYearShift ? 'YEAR_' . $fiscalYearShift : 'YEAR'; $item = $function . ':' . $argument; } else if ($function === 'QUARTER_FISCAL') { $fiscalYearShift = $this->config->get('fiscalYearShift', 0); $function = $fiscalYearShift ? 'QUARTER_' . $fiscalYearShift : 'QUARTER'; $item = $function . ':' . $argument; } else if ($function === 'WEEK') { $function = $this->config->get('weekStart') ? 'WEEK_1' : 'WEEK_0'; $item = $function . ':' . $argument; } if (!str_contains($item, '.')) { $fieldType = $this->metadata->get(['entityDefs', $entityType, 'fields', $argument, 'type']); if (in_array($fieldType, ['link', 'file', 'image'])) { if (!$queryBuilder->hasLeftJoinAlias($item)) { $queryBuilder->leftJoin($item); } $queryBuilder ->select($item . 'Id') ->group($item . 'Id'); return; } if ($fieldType === 'linkParent') { if (!$queryBuilder->hasLeftJoinAlias($item)) { // @todo Revise $queryBuilder->leftJoin($item); } $queryBuilder ->select($item . 'Id') ->select($item . 'Type') ->group($item . 'Id') ->group($item . 'Type'); return; } if ($function && in_array($fieldType, ['datetime', 'datetimeOptional'])) { $tzOffset = (string) $this->getTimeZoneOffset(); if ($tzOffset) { $groupBy = "$function:TZ:($argument,$tzOffset)"; $queryBuilder ->select($groupBy) ->group($groupBy); return; } $queryBuilder ->select($item) ->group($item); return; } $queryBuilder ->select($item) ->group($item); return; } [$link, $field] = explode('.', $argument); $skipSelect = false; $entityDefs = $this->entityManager->getDefs()->getEntity($entityType); if ($entityDefs->hasRelation($link)) { $relationType = $entityDefs->getRelation($link)->getType(); $foreignEntityType = $entityDefs->getRelation($link)->hasForeignEntityType() ? $entityDefs->getRelation($link)->getForeignEntityType() : null; $foreignEntityDefs = $this->entityManager->getDefs()->getEntity($foreignEntityType); $foreignFieldType = $foreignEntityDefs->hasField($field) ? $foreignEntityDefs->getField($field)->getType() : null; if ($foreignEntityDefs->hasRelation($field)) { $foreignRelationType = $foreignEntityDefs->getRelation($field)->getType(); if ( ( $relationType === Entity::BELONGS_TO || $relationType === Entity::HAS_ONE ) && $foreignRelationType === Entity::BELONGS_TO ) { $queryBuilder ->select($item . 'Id') ->group($item . 'Id'); $skipSelect = true; } } if ($function && in_array($foreignFieldType, ['datetime', 'datetimeOptional'])) { $tzOffset = (string) $this->getTimeZoneOffset(); if ($tzOffset) { $skipSelect = true; $groupBy = "$function:TZ:($link.$field,$tzOffset)"; $queryBuilder ->select($groupBy) ->group($groupBy); } } } $this->handleLeftJoins($item, $entityType, $queryBuilder, true); if ($skipSelect) { return; } $queryBuilder ->select($item) ->group($item); } private function handleLeftJoins( string $item, string $entityType, SelectBuilder $queryBuilder, bool $skipDistinct = false ): void { if (str_contains($item, ':')) { $argumentList = QueryComposerUtil::getAllAttributesFromComplexExpression($item); foreach ($argumentList as $argument) { $this->handleLeftJoins($argument, $entityType, $queryBuilder, $skipDistinct); } return; } $entityDefs = $this->entityManager ->getDefs() ->getEntity($entityType); if (str_contains($item, '.')) { [$relation,] = explode('.', $item); if ($queryBuilder->hasLeftJoinAlias($relation)) { return; } $queryBuilder->leftJoin($relation); if (!$entityDefs->hasRelation($relation)) { return; } $relationType = $entityDefs->getRelation($relation)->getType(); if ( !$skipDistinct && in_array($relationType, [ Entity::HAS_MANY, Entity::MANY_MANY, Entity::HAS_CHILDREN, ]) ) { // @todo Remove when v8.5 is min. supported. $queryBuilder->distinct(); } return; } if (!$entityDefs->hasAttribute($item)) { return; } $attributeDefs = $entityDefs->getAttribute($item); if ($attributeDefs->getType() === Entity::FOREIGN) { $relation = $attributeDefs->getParam('relation'); if ($relation && !$queryBuilder->hasLeftJoinAlias($relation)) { $queryBuilder->leftJoin($relation); } } } /** * @param string[] $columns * @param bool $isList Should be true only for List report. Should not be true for Sub-List. */ public function handleColumns(array $columns, SelectBuilder $queryBuilder, bool $isList = false): void { $entityType = $queryBuilder->build()->getFrom(); if (!$entityType) { throw new LogicException("No from."); } foreach ($columns as $item) { $this->handleColumnsItem($item, $entityType, $queryBuilder, $isList); } } /** * @todo Use the selectDefs attribute dependency map? Or not needed as already applied with the select manager. */ private function handleColumnsItem( string $item, string $entityType, SelectBuilder $queryBuilder, bool $isList = false, ): void { $columnData = $this->gridHelper->getDataFromColumnName($entityType, $item); $entityDefs = $this->entityManager->getDefs()->getEntity($entityType); if ($columnData->function && !$columnData->link && $columnData->field) { $this->addSelect($item, $queryBuilder); return; } if ($columnData->link) { $this->handleLeftJoins($item, $entityType, $queryBuilder); if (in_array($columnData->fieldType, ['link', 'file', 'image'])) { $this->addSelect($item . 'Id', $queryBuilder); return; } $this->addSelect($item, $queryBuilder); return; } if ($isList) { return; } if (str_contains($item, ':') && str_contains($item, '.')) { $this->handleLeftJoins($item, $entityType, $queryBuilder); } $type = $columnData->fieldType; if (in_array($type, ['link', 'file', 'image', 'linkOne'])) { $this->addSelect($item . 'Name', $queryBuilder); $this->addSelect($item . 'Id', $queryBuilder); if (!$queryBuilder->hasLeftJoinAlias($item)) { $queryBuilder->leftJoin($item); } return; } if ($type === 'linkParent') { $this->addSelect($item . 'Type', $queryBuilder); $this->addSelect($item . 'Id', $queryBuilder); return; } if ($type === 'currency') { $this->addSelect($item, $queryBuilder); if (!$entityDefs->tryGetField($item)?->getParam('notStorable')) { $this->addSelect($item . 'Currency', $queryBuilder); $this->addSelect($item . 'Converted', $queryBuilder); } return; } if ($type === 'duration') { $start = $this->metadata->get(['entityDefs', $entityType, 'fields', $item, 'start']); $end = $this->metadata->get(['entityDefs', $entityType , 'fields', $item, 'end']); $this->addSelect($start, $queryBuilder); $this->addSelect($end, $queryBuilder); $this->addSelect($item, $queryBuilder); return; } if ($type === 'personName') { $this->addSelect($item, $queryBuilder); $this->addSelect('first' . ucfirst($item), $queryBuilder); $this->addSelect('last' . ucfirst($item), $queryBuilder); return; } if ($type === 'address') { $pList = ['city', 'country', 'postalCode', 'street', 'state']; foreach ($pList as $p) { $this->addSelect($item . ucfirst($p), $queryBuilder); } return; } if ($type === 'datetimeOptional') { $this->addSelect($item, $queryBuilder); $this->addSelect($item . 'Date', $queryBuilder); return; } if ($type === 'linkMultiple' || $type === 'attachmentMultiple') { return; } $this->addSelect($item, $queryBuilder); } private function isInSelect(string $item, SelectBuilder $queryBuilder): bool { $currentList = array_map( function (Selection $selection): string { return $selection->getExpression()->getValue(); }, $queryBuilder->build()->getSelect() ); return in_array($item, $currentList); } private function addSelect(string $item, SelectBuilder $queryBuilder): void { if ($this->isInSelect($item, $queryBuilder)) { return; } $alias = $this->gridUtil->sanitizeSelectAlias($item); $queryBuilder->select($item, $alias); } /** * @param string[] $orderBy */ public function handleOrderBy(array $orderBy, SelectBuilder $queryBuilder): void { $entityType = $queryBuilder->build()->getFrom(); foreach ($orderBy as $item) { $this->handleOrderByItem($item, $entityType, $queryBuilder); } } private function handleOrderByItem(string $item, string $entityType, SelectBuilder $queryBuilder): void { $entityDefs = $this->entityManager->getDefs()->getEntity($entityType); if (str_contains($item, 'LIST:')) { // @todo Check is actual as processed afterwards. $orderBy = substr($item, 5); if (str_contains($orderBy, '.')) { [$rel, $field] = explode('.', $orderBy); if (!$entityDefs->hasRelation($rel)) { return; } $relationDefs = $entityDefs->getRelation($rel); $foreignEntityType = $relationDefs->hasForeignEntityType() ? $relationDefs->getForeignEntityType() : null; if (!$foreignEntityType) { return; } $optionList = $this->metadata ->get(['entityDefs', $foreignEntityType, 'fields', $field, 'options']) ?? []; } else { $optionList = $this->metadata->get(['entityDefs', $entityType, 'fields', $orderBy, 'options']) ?? []; } if (!$optionList) { return; } $queryBuilder->order( Order::createByPositionInList(Expression::column($orderBy), $optionList) ); return; } if (str_contains($item, 'ASC:')) { $orderBy = substr($item, 4); $order = 'ASC'; } else if (str_contains($item, 'DESC:')) { $orderBy = substr($item, 5); $order = 'DESC'; } else { return; } $field = $orderBy; $orderEntityType = $entityType; $link = null; if (str_contains($orderBy, '.')) { [$link, $field] = explode('.', $orderBy); if (!$entityDefs->hasRelation($link)) { return; } $relationDefs = $entityDefs->getRelation($link); $orderEntityType = $relationDefs->hasForeignEntityType() ? $relationDefs->getForeignEntityType() : null; if (!$orderEntityType) { return; } } $entityDefs = $this->entityManager->getDefs()->getEntity($orderEntityType); $fieldType = $entityDefs->hasField($field) ? $entityDefs->getField($field)->getType() : null; if (in_array($fieldType, ['link', 'file', 'image'])) { /*if ($link) { continue; }*/ // MariaDB issue with ONLY_FULL_GROUP_BY. /*$orderBy = $orderBy . 'Name'; if (!in_array($orderBy, $params['select'])) { $params['select'][] = $orderBy; }*/ return; } if ($fieldType === 'linkParent') { if ($link) { return; } $orderBy = $orderBy . 'Type'; } if (!$this->isInSelect($orderBy, $queryBuilder)) { return; } $queryBuilder->order($orderBy, $order); } /** * @throws BadRequest */ public function handleFiltersWhere( WhereItem $whereItem, SelectBuilder $queryBuilder/*, bool $isGrid = false*/ ): void { $entityType = $queryBuilder->build()->getFrom(); if (!$entityType) { throw new LogicException("No from."); } $this->applyWhereFilterAdditionalAppliers($entityType, $whereItem, $queryBuilder); // Supposed to be applied by the scanner. //$this->applyLeftJoinsFromWhere($whereItem, $queryBuilder); $params = $this->supportsHasManySubQuery() ? new Converter\Params(useSubQueryIfMany: true) : null; $whereClause = $this->createConverter($entityType) ->convert($queryBuilder, $whereItem, $params); $queryBuilder->where($whereClause); /*if (!$isGrid) { // Distinct is already supposed to be applied by the scanner. $this->applyDistinctFromWhere($whereItem, $queryBuilder); }*/ } private function supportsHasManySubQuery(): bool { return class_exists("Espo\\Core\\Select\\Where\\Converter\\Params"); } /** * @throws BadRequest */ public function handleFiltersHaving( WhereItem $havingItem, SelectBuilder $queryBuilder, bool $isGrid = false ): void { $entityType = $queryBuilder->build()->getFrom(); if (!$entityType) { throw new LogicException("No from."); } if ($havingItem->getItemList() === []) { return; } $converter = $this->createConverter($entityType); if ($isGrid) { // Supposed to be applied by the scanner. //$this->applyLeftJoinsFromWhere($havingItem, $queryBuilder); $havingClause = $converter->convert($queryBuilder, $havingItem); $queryBuilder->having($havingClause); return; } $subQueryBuilder = SelectBuilder::create() ->from($entityType, lcfirst($entityType)) ->select('id') ->group('id'); $havingClause = $converter->convert($subQueryBuilder, $havingItem); $subQueryBuilder->having($havingClause); // Supposed to be applied by the scanner. //$this->applyLeftJoinsFromWhere($havingItem, $subQueryBuilder); $queryBuilder->where(['id=s' => $subQueryBuilder->build()->getRaw()]); } /*public function applyLeftJoinsFromWhere(WhereItem $item, SelectBuilder $queryBuilder): void { $entityType = $queryBuilder->build()->getFrom(); if (!$entityType) { throw new LogicException(); } //if ($queryBuilder->build()->isDistinct()) { // return; //} if (in_array($item->getType(), [self::WHERE_TYPE_OR, self::WHERE_TYPE_AND])) { foreach ($item->getItemList() as $listItem) { $this->applyLeftJoinsFromWhere($listItem, $queryBuilder); } return; } if (!$item->getAttribute()) { return; } $this->handleLeftJoins($item->getAttribute(), $entityType, $queryBuilder, true); }*/ /** * @deprecated As of v3.4.7. * @todo Remove when v8.5 is min. supported. */ public function applyDistinctFromWhere(WhereItem $item, SelectBuilder $queryBuilder): void { if ($this->supportsHasManySubQuery()) { return; } if ($queryBuilder->build()->isDistinct()) { return; } $entityType = $queryBuilder->build()->getFrom(); if (!$entityType) { throw new LogicException(); } if (in_array($item->getType(), [self::WHERE_TYPE_OR, self::WHERE_TYPE_AND])) { foreach ($item->getItemList() as $listItem) { /** @noinspection PhpDeprecationInspection */ $this->applyDistinctFromWhere($listItem, $queryBuilder); } return; } if (!$item->getAttribute()) { return; } $this->handleDistinct($item->getAttribute(), $entityType, $queryBuilder); } private function handleDistinct(string $item, string $entityType, SelectBuilder $queryBuilder): void { if (str_contains($item, ':')) { $argumentList = QueryComposerUtil::getAllAttributesFromComplexExpression($item); foreach ($argumentList as $argument) { $this->handleDistinct($argument, $entityType, $queryBuilder); } return; } if (!str_contains($item, '.')) { return; } [$relation,] = explode('.', $item); $entityDefs = $this->entityManager->getDefs()->getEntity($entityType); if (!$entityDefs->hasRelation($relation)) { return; } $relationsDefs = $entityDefs->getRelation($relation); if (in_array($relationsDefs->getType(), [Entity::HAS_MANY, Entity::MANY_MANY])) { $queryBuilder->distinct(); } } /** * @return float|int */ private function getTimeZoneOffset() { $timeZone = $this->config->get('timeZone', 'UTC'); if ($timeZone === 'UTC') { return 0; } try { $dateTimeZone = new DateTimeZone($timeZone); $dateTime = new DateTime('now', $dateTimeZone); $dateTime->modify('first day of january'); $tzOffset = $dateTimeZone->getOffset($dateTime) / 3600; } catch (Exception) { return 0; } return $tzOffset; } private function createConverter(string $entityType): Converter { return $this->converterFactory->create($entityType, $this->user); } /** * @return class-string[] */ private function getWhereFiltersApplierClassNameList(string $entityType): array { /** @var class-string[] */ return $this->metadata ->get("app.advancedReport.entityParams.$entityType.whereFilterAdditionalApplierClassNameList") ?? []; } /** * @param class-string $className */ private function createAdditionalApplier(string $entityType, string $className): AdditionalApplier { return $this->injectableFactory->createWithBinding( $className, BindingContainerBuilder::create() ->bindInstance(User::class, $this->user) ->inContext($className, function (ContextualBinder $binder) use ($entityType) { $binder->bindValue('$entityType', $entityType); }) ->build() ); } private function applyWhereFilterAdditionalAppliers( string $entityType, WhereItem $whereItem, SelectBuilder $queryBuilder, ): void { $additionalApplierClassNameList = $this->getWhereFiltersApplierClassNameList($entityType); foreach ($additionalApplierClassNameList as $className) { $applier = $this->createAdditionalApplier($entityType, $className); $searchParams = SearchParams::create()->withWhere($whereItem); $applier->apply($queryBuilder, $searchParams); } } }