diff --git a/core/legacy/dbobjectsearchlegacy.class.php b/core/legacy/dbobjectsearchlegacy.class.php index 90ff4d331..f4d3e5e93 100644 --- a/core/legacy/dbobjectsearchlegacy.class.php +++ b/core/legacy/dbobjectsearchlegacy.class.php @@ -2602,9 +2602,18 @@ class DBObjectSearch extends DBSearch return $oExpression; } + /** + * @param array $aAttCodes array of attCodes to search into + * @param string $sNeedle one word to be searched + * + * @throws \CoreException + */ + public function AddCondition_FullTextOnAttributes(array $aAttCodes, $sNeedle) + { + } + public function ListParameters() { return $this->GetCriteria()->ListParameters(); } - } diff --git a/core/oql/expression.class.inc.php b/core/oql/expression.class.inc.php index e12589412..91579b385 100644 --- a/core/oql/expression.class.inc.php +++ b/core/oql/expression.class.inc.php @@ -1919,6 +1919,39 @@ class FieldExpression extends UnaryExpression // Has been resolved into an SQL expression class FieldExpressionResolved extends FieldExpression { + protected $m_aAdditionalExpressions; + + public function __construct($mExpression, $sParent = '') + { + $this->m_aAdditionalExpressions = array(); + if (is_array($mExpression)) + { + foreach ($mExpression as $sSuffix => $sExpression) + { + if ($sSuffix == '') + { + $sName = $sExpression; + } + $this->m_aAdditionalExpressions[$sSuffix] = new FieldExpressionResolved($sExpression, $sParent); + } + } + else + { + $sName = $mExpression; + } + + parent::__construct($sName, $sParent); + } + + /** + * @return array of additional expressions for muti-column attributes + * @since 2.7.4 + */ + public function AdditionalExpressions() + { + return $this->m_aAdditionalExpressions; + } + public function GetUnresolvedFields($sAlias, &$aUnresolved) { } diff --git a/core/oqlclassnode.class.inc.php b/core/oqlclassnode.class.inc.php index da157f588..3c078ab14 100644 --- a/core/oqlclassnode.class.inc.php +++ b/core/oqlclassnode.class.inc.php @@ -331,4 +331,12 @@ class OQLJoin return $this->sRightField; } + /** + * @return string + */ + public function GetLeftField() + { + return $this->sLeftField; + } + } diff --git a/core/oqlclasstreeoptimizer.class.inc.php b/core/oqlclasstreeoptimizer.class.inc.php index 0472863e7..1e12384e9 100644 --- a/core/oqlclasstreeoptimizer.class.inc.php +++ b/core/oqlclasstreeoptimizer.class.inc.php @@ -50,8 +50,15 @@ class OQLClassTreeOptimizer { if ($oJoin->IsOutbound()) { - // The join is not used, remove from tree - $oCurrentClassNode->RemoveJoin($sLeftKey, $index); + // If joined class in not the same class than the external key target class + // then the join cannot be removed because it is used to filter the request + $sJoinedClass = $oJoin->GetOOQLClassNode()->GetNodeClass(); + $sExtKeyAttCode = $oJoin->GetLeftField(); + $oExtKeyAttDef = MetaModel::GetAttributeDef($oCurrentClassNode->GetNodeClass(), $sExtKeyAttCode); + if ($sJoinedClass == $oExtKeyAttDef->GetTargetClass()) { + // The join is not used, remove from tree + $oCurrentClassNode->RemoveJoin($sLeftKey, $index); + } } else { diff --git a/core/querybuilderexpressions.class.inc.php b/core/querybuilderexpressions.class.inc.php index 5f3aca1cf..c713610a8 100644 --- a/core/querybuilderexpressions.class.inc.php +++ b/core/querybuilderexpressions.class.inc.php @@ -178,6 +178,14 @@ class QueryBuilderExpressions foreach ($this->m_aSelectExpr as $sColAlias => $oExpr) { $this->m_aSelectExpr[$sColAlias] = $oExpr->Translate($aTranslationData, $bMatchAll, $bMarkFieldsAsResolved); + if ($this->m_aSelectExpr[$sColAlias] instanceof FieldExpressionResolved) + { + // Split the field with the relevant alias + foreach ($this->m_aSelectExpr[$sColAlias]->AdditionalExpressions() as $sSuffix => $oAdditionalExpr) + { + $this->m_aSelectExpr[$sColAlias.$sSuffix] = $oAdditionalExpr->Translate($aTranslationData, $bMatchAll, $bMarkFieldsAsResolved); + } + } } if ($this->m_aGroupByExpr) { diff --git a/core/sqlobjectquerybuilder.class.inc.php b/core/sqlobjectquerybuilder.class.inc.php index b1ecb1d43..4d85480ba 100644 --- a/core/sqlobjectquerybuilder.class.inc.php +++ b/core/sqlobjectquerybuilder.class.inc.php @@ -239,24 +239,16 @@ class SQLObjectQueryBuilder continue; } $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); - foreach ($oAttDef->GetSQLExpressions() as $sColId => $sSQLExpr) + $oFieldSQLExp = new FieldExpressionResolved($oAttDef->GetSQLExpressions(), $sClassAlias); + /** + * @var string $sPluginClass + * @var iQueryModifier $oQueryModifier + */ + foreach (MetaModel::EnumPlugins('iQueryModifier') as $sPluginClass => $oQueryModifier) { - if (!empty($sColId)) - { - // Multi column attributes - $oBuild->m_oQBExpressions->AddSelect($sSelectedClassAlias.$sAttCode.$sColId, new FieldExpression($sAttCode.$sColId, $sClassAlias)); - } - $oFieldSQLExp = new FieldExpressionResolved($sSQLExpr, $sClassAlias); - /** - * @var string $sPluginClass - * @var iQueryModifier $oQueryModifier - */ - foreach (MetaModel::EnumPlugins('iQueryModifier') as $sPluginClass => $oQueryModifier) - { - $oFieldSQLExp = $oQueryModifier->GetFieldExpression($oBuild, $sClass, $sAttCode, $sColId, $oFieldSQLExp, $oBaseSQLQuery); - } - $aTranslation[$sClassAlias][$sAttCode.$sColId] = $oFieldSQLExp; + $oFieldSQLExp = $oQueryModifier->GetFieldExpression($oBuild, $sClass, $sAttCode, '', $oFieldSQLExp, $oBaseSQLQuery); } + $aTranslation[$sClassAlias][$sAttCode] = $oFieldSQLExp; } // Translate the selected columns diff --git a/dictionaries/nl.dictionary.itop.ui.php b/dictionaries/nl.dictionary.itop.ui.php index b3b6ec311..6db099142 100644 --- a/dictionaries/nl.dictionary.itop.ui.php +++ b/dictionaries/nl.dictionary.itop.ui.php @@ -813,7 +813,7 @@ Dict::Add('NL NL', 'Dutch', 'Nederlands', array( 'UI:Delete:WillBeDeletedAutomatically' => 'Zal automatisch verwijderd worden', 'UI:Delete:MustBeDeletedManually' => 'Moet handmatig verwijderd worden', 'UI:Delete:CannotUpdateBecause_Issue' => 'Zou automatisch moeten geüpdatet worden, maar: %1$s', - 'UI:Delete:WillAutomaticallyUpdate_Fields' => 'Zal automatisch aangeapst worden (reset: %1$s)', + 'UI:Delete:WillAutomaticallyUpdate_Fields' => 'Zal automatisch aangepast worden (reset: %1$s)', 'UI:Delete:Count_Objects/LinksReferencing_Object' => '%1$d objecten/links verwijzen naar %2$s', 'UI:Delete:Count_Objects/LinksReferencingTheObjects' => '%1$d objecten/links verwijzen naar sommige objecten die verwijderd worden', 'UI:Delete:ReferencesMustBeDeletedToEnsureIntegrity' => 'Elke verdere verwijzing moet verwijderd worden om de integriteit van de database te verzekeren', diff --git a/templates/pages/login/login.html.twig b/templates/pages/login/login.html.twig index fed41b314..14656d867 100644 --- a/templates/pages/login/login.html.twig +++ b/templates/pages/login/login.html.twig @@ -12,7 +12,7 @@ {% if bFailedLogin %}

{{ sMessage }}

{% else %} -

{{ sMessage }}

+

{{ sMessage|raw }}

{% endif %} {% endblock login_title %} diff --git a/test/ItopTestCase.php b/test/ItopTestCase.php index 12766eea8..a2b9f48f6 100644 --- a/test/ItopTestCase.php +++ b/test/ItopTestCase.php @@ -92,7 +92,31 @@ class ItopTestCase extends TestCase { $sId = str_replace('"', '', $this->getName()); $sId = str_replace(' ', '_', $sId); + return $sId; } + public function InvokeNonPublicStaticMethod($sObjectClass, $sMethodName, $aArgs) + { + return $this->InvokeNonPublicMethod($sObjectClass, $sMethodName, null, $aArgs); + } + + /** + * @param string $sObjectClass for example DBObject::class + * @param string $sMethodName + * @param object $oObject + * @param array $aArgs + * + * @return mixed method result + * + * @throws \ReflectionException + */ + public function InvokeNonPublicMethod($sObjectClass, $sMethodName, $oObject, $aArgs) + { + $class = new \ReflectionClass($sObjectClass); + $method = $class->getMethod($sMethodName); + $method->setAccessible(true); + + return $method->invokeArgs($oObject, $aArgs); + } } \ No newline at end of file diff --git a/test/core/DBSearchUpdateRealiasingMapTest.php b/test/core/DBSearchUpdateRealiasingMapTest.php index ece5dd312..dd090bdc9 100644 --- a/test/core/DBSearchUpdateRealiasingMapTest.php +++ b/test/core/DBSearchUpdateRealiasingMapTest.php @@ -33,7 +33,9 @@ class DBSearchUpdateRealiasingMapTest extends ItopDataTestCase */ public function testUpdateRealiasingMap($aRealiasingMap, $aAliasTranslation, $aExpectedRealiasingMap) { - $this->UpdateRealiasingMap($aRealiasingMap, $aAliasTranslation); + $oObject = new DBObjectSearch('Organization'); + $aArgs = [&$aRealiasingMap, $aAliasTranslation]; + $this->InvokeNonPublicMethod(DBObjectSearch::class, 'UpdateRealiasingMap', $oObject, $aArgs); $this->assertEquals($aExpectedRealiasingMap, $aRealiasingMap); } @@ -77,12 +79,4 @@ class DBSearchUpdateRealiasingMapTest extends ItopDataTestCase ], ]; } - - private function UpdateRealiasingMap(&$aRealiasingMap, $aAliasTranslation) - { - $class = new \ReflectionClass(DBObjectSearch::class); - $method = $class->getMethod('UpdateRealiasingMap'); - $method->setAccessible(true); - $method->invokeArgs(new DBObjectSearch('Organization'), [&$aRealiasingMap, $aAliasTranslation]); - } } diff --git a/test/core/OQLTest.php b/test/core/OQLTest.php index 4476f56e9..73019113b 100644 --- a/test/core/OQLTest.php +++ b/test/core/OQLTest.php @@ -15,7 +15,10 @@ use Combodo\iTop\Test\UnitTest\ItopDataTestCase; use DBObjectSearch; use DBSearch; use Exception; +use MetaModel; use OqlInterpreter; +use QueryBuilderContext; +use SQLObjectQueryBuilder; use utils; /** @@ -51,6 +54,7 @@ class OQLTest extends ItopDataTestCase * @group itopConfigMgmt * @group itopRequestMgmt * @dataProvider NestedQueryProvider + * @depends testOQLSetup * * @param $sQuery * @@ -410,44 +414,103 @@ class OQLTest extends ItopDataTestCase ); } - /** - * @dataProvider bug3618Provider - * @doesNotPerformAssertions - */ - public function testBug3618($sOQL, $sExplanation) - { - $this->markTestSkipped(); - return; - if (is_string($sExplanation)) { - $this->debug($sExplanation); - } - $oSearch = DBSearch::FromOQL($sOQL); - $oSet = new \CMDBObjectSet($oSearch); - $oSet->CountWithLimit(1); + /** + * @dataProvider GetOQLClassTreeProvider + * @param $sOQL + * @param $sExpectedOQL + */ + public function testGetOQLClassTree($sOQL, $sExpectedOQL) + { + $oFilter = DBSearch::FromOQL($sOQL); + $aCountAttToLoad = array(); + $sMainClass = null; + foreach ($oFilter->GetSelectedClasses() as $sClassAlias => $sClass) + { + $aCountAttToLoad[$sClassAlias] = array(); + if (empty($sMainClass)) + { + $sMainClass = $sClass; + } + } + $aModifierProperties = MetaModel::MakeModifierProperties($oFilter); + $oSQLObjectQueryBuilder = new SQLObjectQueryBuilder($oFilter); + $oBuild = new QueryBuilderContext($oFilter, $aModifierProperties, null, null, null, $aCountAttToLoad); + $sResultOQL = $oSQLObjectQueryBuilder->DebugOQLClassTree($oBuild); + + static::assertEquals($sExpectedOQL, $sResultOQL); } - public function bug3618Provider() + public function GetOQLClassTreeProvider() { return [ - 'ok' => [ - 'sOql' => "SELECT UserRequest WHERE private_log LIKE '%FOO : %' - - UNION - - SELECT Ticket WHERE private_log LIKE '%BAR : %'", - 'sExplanation' => null, + 'Bug 3660 1' => [ + "SELECT UserRequest AS U JOIN lnkContactToTicket AS l ON l.ticket_id=U.id JOIN Team AS T ON l.contact_id=T.id", + "SELECT `U` FROM `UserRequest` AS `U` + INNER JOIN `lnkContactToTicket` AS `l` + ON `U`.`id` = `l`.`ticket_id` + INNER JOIN `Team` AS `T` + ON `l`.`contact_id` = `T`.`id`", ], - - 'KO: different number of columns' => [ - 'sOql' => "SELECT UserRequest WHERE private_log LIKE '%FOO : %' - - UNION - - SELECT Ticket ", - 'sExplanation' => 'The UNION is composed by two SELECT stmt, the 1st one has two columns the second one has only one column: BOOM!', + 'Bug 3660 2' => [ + "SELECT UserRequest AS U JOIN lnkContactToTicket AS l ON l.ticket_id=U.id JOIN Contact AS C ON l.contact_id=C.id", + "SELECT `U` FROM `UserRequest` AS `U` + INNER JOIN `lnkContactToTicket` AS `l` + ON `U`.`id` = `l`.`ticket_id`", ], ]; } + /** + * @dataProvider MakeSelectQueryForCountProvider + * + * @param $sOQL + * @param $sExpectedSQL + * + * @throws \CoreException + * @throws \MissingQueryArgument + * @throws \OQLException + */ + public function testMakeSelectQueryForCount($sOQL, $sExpectedSQL) + { + $oFilter = DBSearch::FromOQL($sOQL); + // Avoid adding all the fields for counts or "group by" requests + $aCountAttToLoad = array(); + $sMainClass = null; + foreach ($oFilter->GetSelectedClasses() as $sClassAlias => $sClass) { + $aCountAttToLoad[$sClassAlias] = array(); + if (empty($sMainClass)) { + $sMainClass = $sClass; + } + } + $sSQL = $oFilter->MakeSelectQuery([], [], $aCountAttToLoad, null, 0, 0, true); + static::assertEquals($sExpectedSQL, $sSQL); + } + + public function MakeSelectQueryForCountProvider() + { + return [ + 'Bug 3618' => [ + "SELECT UserRequest WHERE private_log LIKE '%Auteur : %' UNION SELECT UserRequest", + "SELECT COUNT(*) AS COUNT FROM (SELECT + 1 + FROM ( +SELECT + DISTINCT `UserRequest_Ticket`.`id` AS `UserRequestid` + FROM + `ticket` AS `UserRequest_Ticket` + WHERE ((`UserRequest_Ticket`.`private_log` LIKE '%Auteur : %') AND COALESCE((`UserRequest_Ticket`.`finalclass` IN ('UserRequest')), 1)) + + UNION + SELECT + DISTINCT `UserRequest`.`id` AS `UserRequestid` + FROM + `ticket_request` AS `UserRequest` + WHERE 1 + +) as __selects__ +) AS _union_alderaan_", + ], + ]; + } }