diff --git a/core/oql/expression.class.inc.php b/core/oql/expression.class.inc.php index 65ba8ea7b..e017bd1b0 100644 --- a/core/oql/expression.class.inc.php +++ b/core/oql/expression.class.inc.php @@ -448,6 +448,7 @@ class BinaryExpression extends Expression public function GetCriterion($oSearch, &$aArgs = null, $bRetrofitParams = false, $oAttDef = null) { + $bReverseOperator = false; $oLeftExpr = $this->GetLeftExpr(); $oRightExpr = $this->GetRightExpr(); if ($oLeftExpr instanceof FieldExpression && $oRightExpr instanceof FieldExpression) @@ -462,6 +463,7 @@ class BinaryExpression extends Expression if ($oRightExpr instanceof FieldExpression) { $oAttDef = $oRightExpr->GetAttDef($oSearch->GetJoinedClasses()); + $bReverseOperator = true; } if (is_null($oAttDef)) @@ -474,7 +476,33 @@ class BinaryExpression extends Expression $aCriteria = array_merge($aCriteriaLeft, $aCriteriaRight); - $aCriteria['operator'] = $this->GetOperator(); + if ($bReverseOperator) + { + // switch left and right expressions so reverse the operator + // Note that the operation is the same so < becomes > and not >= + switch ($this->GetOperator()) + { + case '>': + $aCriteria['operator'] = '<'; + break; + case '<': + $aCriteria['operator'] = '>'; + break; + case '>=': + $aCriteria['operator'] = '<='; + break; + case '<=': + $aCriteria['operator'] = '>='; + break; + default: + $aCriteria['operator'] = $this->GetOperator(); + break; + } + } + else + { + $aCriteria['operator'] = $this->GetOperator(); + } $aCriteria['oql'] = $this->Render($aArgs, $bRetrofitParams); return $aCriteria; diff --git a/pages/ajax.searchform.php b/pages/ajax.searchform.php new file mode 100644 index 000000000..04c08643f --- /dev/null +++ b/pages/ajax.searchform.php @@ -0,0 +1,81 @@ + + * + */ + +use Combodo\iTop\Application\Search\AjaxSearchException; +use Combodo\iTop\Application\Search\CriterionParser; + +require_once('../approot.inc.php'); +require_once(APPROOT.'/application/application.inc.php'); +require_once(APPROOT.'/application/webpage.class.inc.php'); +require_once(APPROOT.'/application/ajaxwebpage.class.inc.php'); +require_once(APPROOT.'/application/startup.inc.php'); +require_once(APPROOT.'/application/user.preferences.class.inc.php'); +require_once(APPROOT.'/application/loginwebpage.class.inc.php'); +require_once(APPROOT.'/sources/application/search/ajaxsearchexception.class.inc.php'); +require_once(APPROOT.'/sources/application/search/criterionparser.class.inc.php'); + +try +{ + if (LoginWebPage::EXIT_CODE_OK != LoginWebPage::DoLoginEx(null /* any portal */, false, LoginWebPage::EXIT_RETURN)) + { + throw new SecurityException('You must be logged in'); + } + + $sParams = stripslashes(utils::ReadParam('params', '', false, 'raw_data')); + if (!$sParams) + { + throw new AjaxSearchException("Invalid query (empty filter)", 400); + } + + $oPage = new ajax_page(""); + $oPage->no_cache(); + $oPage->SetContentType('text/html'); + + $aParams = json_decode($sParams, true); + $sOQL = CriterionParser::Parse($aParams['base_oql'], $aParams['criterion']); + $oFilter = DBSearch::FromOQL($sOQL); + $oDisplayBlock = new DisplayBlock($oFilter, 'list', false); + + $aExtraParams['display_limit'] = true; + $aExtraParams['truncated'] = true; + $oDisplayBlock->RenderContent($oPage, $aExtraParams); + + $oPage->output(); + +} catch (AjaxSearchException $e) +{ + http_response_code($e->getCode()); + // note: transform to cope with XSS attacks + echo htmlentities($e->GetMessage(), ENT_QUOTES, 'utf-8'); + IssueLog::Error($e->getMessage()."\nDebug trace:\n".$e->getTraceAsString()); +} catch (SecurityException $e) +{ + http_response_code(403); + // note: transform to cope with XSS attacks + echo htmlentities($e->GetMessage(), ENT_QUOTES, 'utf-8'); + IssueLog::Error($e->getMessage()."\nDebug trace:\n".$e->getTraceAsString()); +} catch (Exception $e) +{ + http_response_code(500); + // note: transform to cope with XSS attacks + echo htmlentities($e->GetMessage(), ENT_QUOTES, 'utf-8'); + IssueLog::Error($e->getMessage()."\nDebug trace:\n".$e->getTraceAsString()); +} \ No newline at end of file diff --git a/sources/application/search/ajaxsearchexception.class.inc.php b/sources/application/search/ajaxsearchexception.class.inc.php new file mode 100644 index 000000000..a3d259674 --- /dev/null +++ b/sources/application/search/ajaxsearchexception.class.inc.php @@ -0,0 +1,35 @@ + + * + */ + +/** + * Created by PhpStorm. + * User: Eric + * Date: 08/03/2018 + * Time: 11:18 + */ + +namespace Combodo\iTop\Application\Search; + + +class AjaxSearchException extends \Exception +{ + +} \ No newline at end of file diff --git a/sources/application/search/criterionconversion.class.inc.php b/sources/application/search/criterionconversion.class.inc.php new file mode 100644 index 000000000..ce98967aa --- /dev/null +++ b/sources/application/search/criterionconversion.class.inc.php @@ -0,0 +1,35 @@ + + * + */ + +/** + * Created by PhpStorm. + * User: Eric + * Date: 08/03/2018 + * Time: 14:47 + */ + +namespace Combodo\iTop\Application\Search; + + +class CriterionConversion +{ + +} \ No newline at end of file diff --git a/sources/application/search/criterionparser.class.inc.php b/sources/application/search/criterionparser.class.inc.php new file mode 100644 index 000000000..98532bd1b --- /dev/null +++ b/sources/application/search/criterionparser.class.inc.php @@ -0,0 +1,114 @@ + + * + */ + +/** + * Created by PhpStorm. + * User: Eric + * Date: 08/03/2018 + * Time: 11:25 + */ + +namespace Combodo\iTop\Application\Search; + + +use DBObjectSearch; +use IssueLog; +use OQLException; + +class CriterionParser +{ + + /** + * @param $sBaseOql + * @param $aCriterion + * + * @return string + */ + public static function Parse($sBaseOql, $aCriterion) + { + $aExpression = array(); + $aOr = $aCriterion['or']; + foreach($aOr as $aAndList) + { + + $sExpression = self::ParseAndList($aAndList['and']); + if (!empty($sExpression)) + { + $aExpression[] = $sExpression; + } + } + + if (empty($aExpression)) + { + return $sBaseOql; + } + + // Sanitize the base OQL + if (strpos($sBaseOql, ' WHERE ')) + { + try + { + $oSearch = DBObjectSearch::FromOQL($sBaseOql); + $oSearch->ResetCondition(); + $sBaseOql = $oSearch->ToOQL(); + } catch (OQLException $e) + { + IssueLog::Error($e->getMessage()); + } + } + + return $sBaseOql.' WHERE '.implode(" OR ", $aExpression).''; + } + + private static function ParseAndList($aAnd) + { + $aExpression = array(); + foreach($aAnd as $aCriteria) + { + $aExpression[] = self::ParseCriteria($aCriteria); + } + + if (empty($aExpression)) + { + return ''; + } + + return '('.implode(" AND ", $aExpression).')'; + } + + private static function ParseCriteria($aCriteria) + { + + if (!empty($aCriteria['oql'])) + { + return $aCriteria['oql']; + } + + // TODO Manage more complicated case + $aRef = explode('.', $aCriteria['ref']); + $sRef = '`'.$aRef[0].'`.`'.$aRef[1].'`'; + + $sOperator = $aCriteria['operator']; + $sValue = $aCriteria['values'][0]['value']; + + return "({$sRef} {$sOperator} '{$sValue}')"; + } +} \ No newline at end of file diff --git a/sources/application/search/searchform.class.inc.php b/sources/application/search/searchform.class.inc.php index ea7b72601..d3c45e4b0 100644 --- a/sources/application/search/searchform.class.inc.php +++ b/sources/application/search/searchform.class.inc.php @@ -112,6 +112,7 @@ class SearchForm $aSearchParams = array( 'criterion_outer_selector' => "#fs_{$sSearchFormId}_criterion_outer", + 'endpoint' => utils::GetAbsoluteUrlAppRoot().'pages/ajax.searchform.php', 'search' => array( 'fields' => $aFields, 'criterion' => $aCriterion, diff --git a/test/application/search/CriterionParserTest.php b/test/application/search/CriterionParserTest.php new file mode 100644 index 000000000..f36068650 --- /dev/null +++ b/test/application/search/CriterionParserTest.php @@ -0,0 +1,86 @@ + + * + */ + +/** + * Created by PhpStorm. + * User: Eric + * Date: 08/03/2018 + * Time: 11:28 + */ + +namespace Combodo\iTop\Test\UnitTest\Application\Search; + +use Combodo\iTop\Application\Search\CriterionParser; +use Combodo\iTop\Test\UnitTest\ItopDataTestCase; + + +class CriterionParserTest extends ItopDataTestCase +{ + /** + * @throws Exception + */ + protected function setUp() + { + parent::setUp(); + + require_once(APPROOT."sources/application/search/criterionparser.class.inc.php"); + } + + public function testParse() + { + $sBaseOql = 'SELECT UserRequest'; + $aCriterion = json_decode('{ + "or": [ + { + "and": [ + { + "ref": "UserRequest.start_date", + "values": [ + { + "value": "2017-01-01", + "label": "2017-01-01 00:00:00" + } + ], + "operator": ">", + "oql": "" + }, + { + "ref": "UserRequest.start_date", + "values": [ + { + "value": "2018-01-01", + "label": "2018-01-01 00:00:00" + } + ], + "operator": "<", + "oql": "(`UserRequest`.`start_date` < \'2018-01-01\')" + } + ] + } + ] +} +', true); + $sOQL = CriterionParser::Parse($sBaseOql, $aCriterion); + + $this->debug($sOQL); + $this->markTestIncomplete(); + } +} diff --git a/test/application/search/SearchFormTest.php b/test/application/search/SearchFormTest.php index 695b6d159..68c3fce7c 100644 --- a/test/application/search/SearchFormTest.php +++ b/test/application/search/SearchFormTest.php @@ -59,6 +59,8 @@ class SearchFormTest extends ItopDataTestCase public function testGetCriterion($sOQL, $iOrCount) { $aCriterion = SearchForm::GetCriterion(\DBObjectSearch::FromOQL($sOQL)); + $aRes = array('base_oql' => $sOQL, 'criterion' => $aCriterion); + $this->debug(json_encode($aRes)); $this->debug($sOQL); $this->debug(json_encode($aCriterion, JSON_PRETTY_PRINT)); $this->assertCount($iOrCount, $aCriterion['or']); @@ -75,7 +77,7 @@ class SearchFormTest extends ItopDataTestCase array('OQL' => "SELECT Contact WHERE status IN ('active', 'inactive')", 1), array('OQL' => "SELECT Contact WHERE status = 'active' OR name LIKE 'toto%'", 2), array('OQL' => "SELECT UserRequest WHERE DATE_SUB(NOW(), INTERVAL 14 DAY) < start_date", 1), - array('OQL' => "SELECT UserRequest WHERE start_date > '2017-01-01' AND start_date < '2018-01-01'", 1), + array('OQL' => "SELECT UserRequest WHERE start_date > '2017-01-01' AND '2018-01-01' >= start_date", 1), ); } }