From acf0548c4c87fc947c3f12df9ecfeeb99531b820 Mon Sep 17 00:00:00 2001 From: Romain Quetiez Date: Fri, 10 Jul 2020 17:26:37 +0200 Subject: [PATCH] =?UTF-8?q?N=C2=B03171=20-=20Friendly=20name=20and=20obsol?= =?UTF-8?q?escence=20flag=20not=20refreshed=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Compute any type of expression on server side - Recompute friendly name and obsolescence flag on server side (DBOBject) - Bonus : compute dependency for external keys --- core/attributedef.class.inc.php | 41 +- core/dbobject.class.php | 100 ++-- core/metamodel.class.php | 5 - core/oql/expression.class.inc.php | 562 +++++++++++++++++- .../datamodel.itop-config-mgmt.xml | 3 - test/ItopDataTestCase.php | 30 +- test/core/DBObjectTest.php | 114 ++++ test/core/ExpressionEvaluateTest.php | 535 +++++++++++++++++ test/core/MetaModelTest.php | 96 +++ 9 files changed, 1435 insertions(+), 51 deletions(-) create mode 100644 test/core/ExpressionEvaluateTest.php diff --git a/core/attributedef.class.inc.php b/core/attributedef.class.inc.php index 7ab6646a8..ce105e2a8 100644 --- a/core/attributedef.class.inc.php +++ b/core/attributedef.class.inc.php @@ -6653,6 +6653,23 @@ class AttributeExternalKey extends AttributeDBFieldVoid return (int)$proposedValue; } + public function GetPrerequisiteAttributes($sClass = null) + { + $aAttributes = parent::GetPrerequisiteAttributes($sClass); + $oExpression = DBSearch::FromOQL($this->GetValuesDef()->GetFilterExpression())->GetCriteria(); + foreach ($oExpression->GetParameters('this') as $sAttCode) + { + // Skip the id as it cannot change anyway + if ($sAttCode =='id') continue; + + if (!in_array($sAttCode, $aAttributes)) + { + $aAttributes[] = $sAttCode; + } + } + return $aAttributes; + } + public function GetMaximumComboLength() { return $this->GetOptional('max_combo_length', MetaModel::GetConfig()->Get('max_combo_length')); @@ -11561,7 +11578,17 @@ class AttributeFriendlyName extends AttributeDefinition public function GetPrerequisiteAttributes($sClass = null) { - return $this->GetOptional("depends_on", array()); + // Code duplicated with AttributeObsolescenceFlag + $aAttributes = $this->GetOptional("depends_on", array()); + $oExpression = $this->GetOQLExpression(); + foreach ($oExpression->ListRequiredFields() as $sClass => $sAttCode) + { + if (!in_array($sAttCode, $aAttributes)) + { + $aAttributes[] = $sAttCode; + } + } + return $aAttributes; } public static function IsScalar() @@ -12793,7 +12820,17 @@ class AttributeObsolescenceFlag extends AttributeBoolean public function GetPrerequisiteAttributes($sClass = null) { - return $this->GetOptional("depends_on", array()); + // Code duplicated with AttributeFriendlyName + $aAttributes = $this->GetOptional("depends_on", array()); + $oExpression = $this->GetOQLExpression(); + foreach ($oExpression->ListRequiredFields() as $sClass => $sAttCode) + { + if (!in_array($sAttCode, $aAttributes)) + { + $aAttributes[] = $sAttCode; + } + } + return $aAttributes; } public function IsDirectField() diff --git a/core/dbobject.class.php b/core/dbobject.class.php index 263592ca5..6b1745a12 100644 --- a/core/dbobject.class.php +++ b/core/dbobject.class.php @@ -569,43 +569,41 @@ abstract class DBObject implements iDisplay $this->Reload(); } - if ($oAttDef->IsExternalKey()) + if ($oAttDef->IsExternalKey() && is_object($value)) { - if (is_object($value)) + // Setting an external key with a whole object (instead of just an ID) + // let's initialize also the external fields that depend on it + // (useful when building objects in memory and not from a query) + /** @var \AttributeExternalKey $oAttDef */ + if ((get_class($value) != $oAttDef->GetTargetClass()) && (!is_subclass_of($value, $oAttDef->GetTargetClass()))) { - // Setting an external key with a whole object (instead of just an ID) - // let's initialize also the external fields that depend on it - // (useful when building objects in memory and not from a query) - /** @var \AttributeExternalKey $oAttDef */ - if ( (get_class($value) != $oAttDef->GetTargetClass()) && (!is_subclass_of($value, $oAttDef->GetTargetClass()))) - { - throw new CoreUnexpectedValue("Trying to set the value of '$sAttCode', to an object of class '".get_class($value)."', whereas it's an ExtKey to '".$oAttDef->GetTargetClass()."'. Ignored"); - } + throw new CoreUnexpectedValue("Trying to set the value of '$sAttCode', to an object of class '".get_class($value)."', whereas it's an ExtKey to '".$oAttDef->GetTargetClass()."'. Ignored"); + } - foreach(MetaModel::ListAttributeDefs(get_class($this)) as $sCode => $oDef) + foreach (MetaModel::ListAttributeDefs(get_class($this)) as $sCode => $oDef) + { + /** @var \AttributeExternalField $oDef */ + if ($oDef->IsExternalField() && ($oDef->GetKeyAttCode() == $sAttCode)) { - /** @var \AttributeExternalField $oDef */ - if ($oDef->IsExternalField() && ($oDef->GetKeyAttCode() == $sAttCode)) - { - /** @var \DBObject $value */ - $this->m_aCurrValues[$sCode] = $value->Get($oDef->GetExtAttCode()); - $this->m_aLoadedAtt[$sCode] = true; - } + /** @var \DBObject $value */ + $this->m_aCurrValues[$sCode] = $value->Get($oDef->GetExtAttCode()); + $this->m_aLoadedAtt[$sCode] = true; + } + elseif (in_array($sAttCode, $oDef->GetPrerequisiteAttributes(get_class($this)))) + { + $this->m_aCurrValues[$sCode] = $this->GetDefaultValue($sCode); + unset($this->m_aLoadedAtt[$sCode]); } } - else if ($this->m_aCurrValues[$sAttCode] != $value) + } + else if ($this->m_aCurrValues[$sAttCode] !== $value) + { + // Invalidate dependent fields so that they get reloaded in case they are needed (See Get()) + // + foreach (MetaModel::GetDependentAttributes(get_class($this), $sAttCode) as $sCode) { - // Setting an external key, but no any other information is available... - // Invalidate the corresponding fields so that they get reloaded in case they are needed (See Get()) - foreach(MetaModel::ListAttributeDefs(get_class($this)) as $sCode => $oDef) - { - /** @var \AttributeExternalKey $oDef */ - if ($oDef->IsExternalField() && ($oDef->GetKeyAttCode() == $sAttCode)) - { - $this->m_aCurrValues[$sCode] = $this->GetDefaultValue($sCode); - unset($this->m_aLoadedAtt[$sCode]); - } - } + $this->m_aCurrValues[$sCode] = $this->GetDefaultValue($sCode); + unset($this->m_aLoadedAtt[$sCode]); } } if ($oAttDef->IsLinkSet() && ($value != null)) @@ -787,20 +785,20 @@ abstract class DBObject implements iDisplay { // Standard case... we have the information directly } - elseif ($this->m_bIsInDB && !$this->m_bDirty) + elseif ($this->m_bIsInDB && !$this->m_bFullyLoaded && !$this->m_bDirty) { // Lazy load (polymorphism): complete by reloading the entire object - // #@# non-scalar attributes.... handle that differently? $oKPI = new ExecutionKPI(); $this->Reload(); $oKPI->ComputeStats('Reload', get_class($this).'/'.$sAttCode); } - elseif ($sAttCode == 'friendlyname') + elseif ($oAttDef->IsBasedOnOQLExpression()) { - // The friendly name is not computed and the object is dirty - // Todo: implement the computation of the friendly name based on sprintf() - // - $this->m_aCurrValues[$sAttCode] = ''; + // Recompute -which is likely to call Get() + // + /** @var AttributeFriendlyName|\AttributeObsolescenceFlag $oAttDef */ + $this->m_aCurrValues[$sAttCode] = $this->EvaluateExpression($oAttDef->GetOQLExpression()); + $this->m_aLoadedAtt[$sAttCode] = true; } else { @@ -5358,5 +5356,33 @@ abstract class DBObject implements iDisplay break; } } + + public function EvaluateExpression(Expression $oExpression) + { + $aFields = $oExpression->ListRequiredFields(); + $aArgs = array(); + foreach ($aFields as $sFieldDesc) + { + $aFieldParts = explode('.', $sFieldDesc); + if (count($aFieldParts) == 2) + { + $sClass = $aFieldParts[0]; + $sAttCode = $aFieldParts[1]; + } + else + { + $sClass = get_class($this); + $sAttCode = $aFieldParts[0]; + } + if (get_class($this) != $sClass) continue; + if (!MetaModel::IsValidAttCode(get_class($this), $sAttCode)) continue; + + $oAttDef = MetaModel::GetAttributeDef(get_class($this), $sAttCode); + $aSQLValues = $oAttDef->GetSQLValues($this->m_aCurrValues[$sAttCode]); + $value = reset($aSQLValues); + $aArgs[$sFieldDesc] = $value; + } + return $oExpression->Evaluate($aArgs); + } } diff --git a/core/metamodel.class.php b/core/metamodel.class.php index 8a0661ea1..be80e20a9 100644 --- a/core/metamodel.class.php +++ b/core/metamodel.class.php @@ -944,11 +944,6 @@ abstract class MetaModel { self::_check_subclass($sClass); $oAtt = self::GetAttributeDef($sClass, $sAttCode); - // Temporary implementation: later, we might be able to compute - // the dependencies, based on the attributes definition - // (allowed values and default values) - - // Even non-writable attributes (like ExternalFields) can now have Prerequisites return $oAtt->GetPrerequisiteAttributes(); } diff --git a/core/oql/expression.class.inc.php b/core/oql/expression.class.inc.php index 8b2e43e27..30e97553a 100644 --- a/core/oql/expression.class.inc.php +++ b/core/oql/expression.class.inc.php @@ -21,6 +21,9 @@ class MissingQueryArgument extends CoreException { } +class NotYetEvaluatedExpression extends CoreException +{ +} /** * @method Check($oModelReflection, array $aAliases, $sSourceQuery) @@ -109,6 +112,47 @@ abstract class Expression */ abstract public function RenderExpression($bForSQL = false, &$aArgs = null, $bRetrofitParams = false); + /** + * Collect parameters, i.e. :parameter + * + * @param null $sParentFilter + * + * @return array + */ + public function GetParameters($sParentFilter = null) + { + $aParameters = array(); + $unused = $this->RenderExpression(false, $aParameters, true); + + if (!is_null($sParentFilter)) $sParentFilter .= '->'; + + $aRet = array(); + foreach($aParameters as $sParameter => $unused) + { + if (is_null($sParentFilter)) + { + $aRet[] = $sParameter; + } + else + { + if (substr($sParameter, 0, strlen($sParentFilter)) == $sParentFilter) + { + $aRet[] = substr($sParameter, strlen($sParentFilter)); + } + } + } + return $aRet; + } + + /** + * Evaluate the value of the expression + * + * @param array $aArgs + * + * @throws \Exception if terms cannot be evaluated as scalars + */ + abstract public function Evaluate(array $aArgs); + /** * Recursively renders the expression as a structure (array) suitable for a JSON export * @@ -319,6 +363,16 @@ class SQLExpression extends Expression return $this->m_sSQL; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + throw new Exception('a nested query cannot be evaluated'); + } + // recursive rendering public function toJSON(&$aArgs = null, $bRetrofitParams = false) { @@ -466,6 +520,149 @@ class BinaryExpression extends Expression return "($sLeft $sOperator $sRight)"; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @return mixed + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + $mLeft = $this->GetLeftExpr()->Evaluate($aArgs); + $mRight = $this->GetRightExpr()->Evaluate($aArgs); + + $sOperator = $this->GetOperator(); + $sType = null; + switch($sOperator) + { + case '+': + case '-': + case '*': + case '/': + $sType = 'maths'; + break; + case '=': + case '!=': + case '<>': + $sType = 'comp'; + break; + case '>': + case '>=': + case '<': + case '<=': + $sType = 'numcomp'; + break; + case 'OR': + case 'AND': + $sType = 'logical'; + break; + case 'LIKE': + $sType = 'like'; + break; + default: + throw new Exception("Operator '$sOperator' not yet supported"); + } + switch ($sType){ + case 'logical': + $bLeft = static::CastToBool($mLeft); + $bRight = static::CastToBool($mRight); + switch ($sOperator) + { + case 'OR': + $result = (int)($bLeft || $bRight); + break; + case 'AND': + $result = (int)($bLeft && $bRight); + break; + default: + throw new Exception("Logic: unknown operator '$sOperator'"); + } + break; + + case 'maths': + $iLeft = (int) $mLeft; + $iRight = (int) $mRight; + switch ($sOperator) + { + case '+' : $result = $iLeft + $iRight; break; + case '-' : $result = $iLeft - $iRight; break; + case '*' : $result = $iLeft * $iRight; break; + case '/' : $result = $iLeft / $iRight; break; + default: + throw new Exception("Logic: unknown operator '$sOperator'"); + } + break; + case 'comp': + $left = $mLeft; + $right = $mRight; + switch ($sOperator) + { + case '=' : $result = ($left == $right); break; + case '!=' : $result = ($left != $right); break; + case '<>' : $result = ($left != $right); break; + default: + throw new Exception("Logic: unknown operator '$sOperator'"); + } + break; + case 'numcomp': + $iLeft = static::ComparableValue($mLeft); + $iRight = static::ComparableValue($mRight); + switch ($sOperator) + { + case '=' : $result = ($iLeft == $iRight); break; + case '>' : $result = ($iLeft > $iRight); break; + case '<' : $result = ($iLeft < $iRight); break; + case '>=' : $result = ($iLeft >= $iRight); break; + case '<=' : $result = ($iLeft <= $iRight); break; + case '!=' : $result = ($iLeft != $iRight); break; + case '<>' : $result = ($iLeft != $iRight); break; + default: + throw new Exception("Logic: unknown operator '$sOperator'"); + } + break; + case 'like': + $sEscaped = preg_quote($mRight, '/'); + $sEscaped = str_replace(array('%', '_', '\\\\.*', '\\\\.'), array('.*', '.', '%', '_'), $sEscaped); + $result = (int) preg_match("/$sEscaped/i", $mLeft); + break; + } + return $result; + } + + static protected function CastToBool($mValue) + { + if (is_string($mValue)) + { + if (is_numeric($mValue)) + { + return abs($mValue) > 0; + } + return false; + } + return (bool)$mValue; + } + static protected function ComparableValue($mixed) + { + if (is_string($mixed)) + { + $oDate = new \DateTime($mixed); + if (($oDate->format('Y-m-d') == $mixed) || ($oDate->format('Y-m-d H:i:s') == $mixed)) + { + $iRet = $oDate->format('U'); + } + else + { + $iRet = (int) $mixed; + } + } + else + { + $iRet = $mixed; + } + + return $iRet; + } + /** * {@inheritDoc} * @throws \MissingQueryArgument @@ -865,6 +1062,16 @@ class MatchExpression extends BinaryExpression return $sRet; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + throw new Exception('evaluation of MATCHES not implemented yet'); + } + public function Translate($aTranslationData, $bMatchAll = true, $bMarkFieldsAsResolved = true) { /** @var \FieldExpression $oLeft */ @@ -903,6 +1110,16 @@ class UnaryExpression extends Expression return CMDBSource::Quote($this->m_value); } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + return $this->m_value; + } + /** * {@inheritDoc} * @throws \MissingQueryArgument @@ -1049,6 +1266,16 @@ class ScalarExpression extends UnaryExpression return $sRet; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + return $this->m_value; + } + /** * {@inheritDoc} * @see Expression::ToJSON() @@ -1357,6 +1584,21 @@ class FieldExpression extends UnaryExpression return "`{$this->m_sParent}`.`{$this->m_sName}`"; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + $sKey = empty($this->m_sParent) ? $this->m_sName : "{$this->m_sParent}.{$this->m_sName}"; + if (!array_key_exists($sKey, $aArgs)) + { + throw new Exception("Missing field '$sKey' from context"); + } + return $aArgs[$sKey]; + } + /** * {@inheritDoc} * @see Expression::ToJSON() @@ -1412,7 +1654,8 @@ class FieldExpression extends UnaryExpression public function ListRequiredFields() { - return array($this->m_sParent.'.'.$this->m_sName); + $sField = empty($this->m_sParent) ? $this->m_sName : "{$this->m_sParent}.{$this->m_sName}"; + return array($sField); } public function CollectUsedParents(&$aTable) @@ -1793,6 +2036,16 @@ class VariableExpression extends UnaryExpression } } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + throw new Exception('not implemented yet'); + } + /** * {@inheritDoc} * @see Expression::ToJSON() @@ -1933,6 +2186,16 @@ class ListExpression extends Expression return '('.implode(', ', $aRes).')'; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + throw new Exception('list expression not yet supported'); + } + /** * {@inheritDoc} * @see Expression::ToJSON() @@ -2140,6 +2403,16 @@ class NestedQueryExpression extends Expression } } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + throw new Exception('a nested query cannot be evaluated'); + } + public function Browse(Closure $callback) { $callback($this); @@ -2240,6 +2513,252 @@ class FunctionExpression extends Expression return $this->m_sVerb.'('.implode(', ', $aRes).')'; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + switch($this->m_sVerb) + { + case 'CONCAT': + $sRet = ''; + foreach ($this->m_aArgs as $iPos => $oExpr) + { + $item = $oExpr->Evaluate($aArgs); + if (is_null($item)) return null; + $sRet .= $item; + } + return $sRet; + + case 'CONCAT_WS': + if (count($this->m_aArgs) < 3) + { + throw new \Exception("Function {$this->m_sVerb} requires at least 3 arguments"); + } + $sSeparator = $this->m_aArgs[0]->Evaluate($aArgs); + foreach ($this->m_aArgs as $iPos => $oExpr) + { + if ($iPos == 0) continue; + $item = $oExpr->Evaluate($aArgs); + if (is_null($item)) return null; + $aStrings[] = $item; + } + $sRet = implode($sSeparator, $aStrings); + return $sRet; + + case 'SUBSTR': + if (count($this->m_aArgs) < 2) + { + throw new \Exception("Function {$this->m_sVerb} requires at least 2 arguments"); + } + $sString = $this->m_aArgs[0]->Evaluate($aArgs); + $iRawPos = $this->m_aArgs[1]->Evaluate($aArgs); + $iPos = $iRawPos > 0 ? + $iRawPos - 1// 0-based in PHP (1-based in SQL) + : $iRawPos; // Negative + if (count($this->m_aArgs) == 2) + { + // Up to the end of the string + $sRet = substr($sString, $iPos); + } + else + { + // Length specified + $iLen = $this->m_aArgs[2]->Evaluate($aArgs); + $sRet = substr($sString, $iPos, $iLen); + } + return $sRet; + + case 'TRIM': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $sRet = trim($this->m_aArgs[0]->Evaluate($aArgs)); + return $sRet; + + case 'INET_ATON': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $sRet = ip2long($this->m_aArgs[0]->Evaluate($aArgs)); + return $sRet; + + case 'INET_NTOA': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $sRet = long2ip($this->m_aArgs[0]->Evaluate($aArgs)); + return $sRet; + + case 'ISNULL': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $sRet = is_null($this->m_aArgs[0]->Evaluate($aArgs)); + return $sRet; + + case 'COALESCE': + if (count($this->m_aArgs) < 1) + { + throw new \Exception("Function {$this->m_sVerb} requires at least 1 argument"); + } + $ret = null; + foreach($this->m_aArgs as $iPos => $oExpr) + { + $ret = $oExpr->Evaluate($aArgs); + if (!is_null($ret)) break; + } + return $ret; + + case 'IF': + if (count($this->m_aArgs) != 3) + { + throw new \Exception("Function {$this->m_sVerb} requires 3 arguments"); + } + $bCond = $this->m_aArgs[0]->Evaluate($aArgs); + if ($bCond) + { + $ret = $this->m_aArgs[1]->Evaluate($aArgs); + } + else + { + $ret = $this->m_aArgs[2]->Evaluate($aArgs); + } + return $ret; + + case 'ELT': + if (count($this->m_aArgs) < 2) + { + throw new \Exception("Function {$this->m_sVerb} requires at least 2 arguments"); + } + // First argument is the 1-based position + $iPosition = (int) $this->m_aArgs[0]->Evaluate($aArgs); + if (($iPosition == 0) || ($iPosition >= count($this->m_aArgs))) + { + // Out of range + $ret = null; + } + else + { + $ret = $this->m_aArgs[$iPosition]->Evaluate($aArgs); + } + return $ret; + + case 'DATE': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $sRet = date('Y-m-d', strtotime($this->m_aArgs[0]->Evaluate($aArgs))); + return $sRet; + + case 'YEAR': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $iRet = (int) date('Y', strtotime($this->m_aArgs[0]->Evaluate($aArgs))); + return $iRet; + + case 'MONTH': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $iRet = (int) date('m', strtotime($this->m_aArgs[0]->Evaluate($aArgs))); + return $iRet; + + case 'DAY': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $iRet = (int) date('d', strtotime($this->m_aArgs[0]->Evaluate($aArgs))); + return $iRet; + + case 'DATE_FORMAT': + if (count($this->m_aArgs) != 2) + { + throw new \Exception("Function {$this->m_sVerb} requires 2 arguments"); + } + $oDate = new DateTime($this->m_aArgs[0]->Evaluate($aArgs)); + $sFormat = $this->m_aArgs[1]->Evaluate($aArgs); + $sFormat = str_replace( + array('%y', '%x', '%w', '%W', '%v', '%T', '%S', '%r', '%p', '%M', '%l', '%k', '%I', '%h', '%b', '%a', '%D', '%c', '%e', '%Y', '%d', '%m', '%H', '%i', '%s'), + array('y', 'o', 'w', 'l', 'W', 'H:i:s', 's', 'h:i:s A', 'A', 'F', 'g', 'H', 'h', 'h','M', 'D', 'jS', 'n', 'j', 'Y', 'd', 'm', 'H', 'i', 's'), + $sFormat); + if (preg_match('/%j/', $sFormat)) + { + $sFormat = str_replace('%j', date_format($oDate, 'z') + 1, $sFormat); + } + if (preg_match('/%[fUuVX]/', $sFormat)) + { + throw new NotYetEvaluatedExpression("Expression ".$this->RenderExpression().' cannot be evaluated (known limitation)'); + } + $sRet = date_format($oDate, $sFormat); + return $sRet; + + case 'TO_DAYS': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $oDate = new DateTime($this->m_aArgs[0]->Evaluate($aArgs)); + $oZero = new DateTime('1582-01-01'); + $iRet = (int) $oDate->diff($oZero)->format('%a') + 577815; + return $iRet; + + case 'FROM_DAYS': + if (count($this->m_aArgs) != 1) + { + throw new \Exception("Function {$this->m_sVerb} requires 1 argument"); + } + $iSince1582 = $this->m_aArgs[0]->Evaluate($aArgs) - 577814; + $oDate = new DateTime("1582-01-01 +$iSince1582 days"); + $sRet = $oDate->format('Y-m-d'); + return $sRet; + + case 'NOW': + $sRet = date('Y-m-d H:i:s'); + return $sRet; + + case 'CURRENT_DATE': + $sRet = date('Y-m-d'); + return $sRet; + + case 'DATE_ADD': + if (count($this->m_aArgs) != 2) + { + throw new \Exception("Function {$this->m_sVerb} requires 2 arguments"); + } + $sStartDate = $this->m_aArgs[0]->Evaluate($aArgs); + $sInterval = $this->m_aArgs[1]->Evaluate($aArgs); + $oDate = new DateTime("$sStartDate +$sInterval"); + $sRet = $oDate->format('Y-m-d H:i:s'); + return $sRet; + + case 'DATE_SUB': + if (count($this->m_aArgs) != 2) + { + throw new \Exception("Function {$this->m_sVerb} requires 2 arguments"); + } + $sStartDate = $this->m_aArgs[0]->Evaluate($aArgs); + $sInterval = $this->m_aArgs[1]->Evaluate($aArgs); + $oDate = new DateTime("$sStartDate -$sInterval"); + $sRet = $oDate->format('Y-m-d H:i:s'); + return $sRet; + + default: + throw new Exception("Function {$this->m_sVerb} cannot be evaluated -unhandled yet"); + } + } + /** * {@inheritDoc} * @see Expression::ToJSON() @@ -2566,6 +3085,17 @@ class IntervalExpression extends Expression return 'INTERVAL '.$this->m_oValue->RenderExpression($bForSQL, $aArgs, $bRetrofitParams).' '.$this->m_sUnit; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + $iValue = $this->m_oValue->Evaluate($aArgs); + return "$iValue {$this->m_sUnit}"; + } + /** * {@inheritDoc} * @see Expression::ToJSON() @@ -2684,6 +3214,21 @@ class CharConcatExpression extends Expression return "CAST(CONCAT(".implode(', ', $aRes).") AS CHAR)"; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + $sRet = ''; + foreach ($this->m_aExpressions as $oExpr) + { + $sRet .= $oExpr->Evaluate($aArgs); + } + return $sRet; + } + /** * {@inheritDoc} * @see Expression::ToJSON() @@ -2825,6 +3370,21 @@ class CharConcatWSExpression extends CharConcatExpression return "CAST(CONCAT_WS($sSep, ".implode(', ', $aRes).") AS CHAR)"; } + /** + * Evaluate the value of the expression + * @param array $aArgs + * @throws \Exception if terms cannot be evaluated as scalars +*/ + public function Evaluate(array $aArgs) + { + $aRes = array(); + foreach ($this->m_aExpressions as $oExpr) + { + $aRes .= $oExpr->Evaluate($aArgs); + } + return implode($this->m_separator, $aRes); + } + public function Browse(Closure $callback) { $callback($this); diff --git a/datamodels/2.x/itop-config-mgmt/datamodel.itop-config-mgmt.xml b/datamodels/2.x/itop-config-mgmt/datamodel.itop-config-mgmt.xml index 4a2950663..af44aa376 100755 --- a/datamodels/2.x/itop-config-mgmt/datamodel.itop-config-mgmt.xml +++ b/datamodels/2.x/itop-config-mgmt/datamodel.itop-config-mgmt.xml @@ -610,9 +610,6 @@ - - - manager_id Person true diff --git a/test/ItopDataTestCase.php b/test/ItopDataTestCase.php index eddbffcc6..9a26e7c10 100644 --- a/test/ItopDataTestCase.php +++ b/test/ItopDataTestCase.php @@ -713,7 +713,7 @@ class ItopDataTestCase extends ItopTestCase $iId = $oLnk->Get('functionalci_id'); if (!empty($aWaitedCIList)) { - $this->assertTrue(array_key_exists($iId, $aWaitedCIList)); + $this->assertArrayHasKey($iId, $aWaitedCIList); $this->assertEquals($aWaitedCIList[$iId], $oLnk->Get('impact_code')); } } @@ -737,7 +737,7 @@ class ItopDataTestCase extends ItopTestCase $iId = $oLnk->Get('contact_id'); if (!empty($aWaitedContactList)) { - $this->assertTrue(array_key_exists($iId, $aWaitedContactList)); + $this->assertArrayHasKey($iId, $aWaitedContactList); foreach ($aWaitedContactList[$iId] as $sAttCode => $oValue) { if (MetaModel::IsValidAttCode(get_class($oTicket), $sAttCode)) @@ -756,5 +756,29 @@ class ItopDataTestCase extends ItopTestCase $this->iTestOrgId = $oOrg->GetKey(); } - + /** + * Assert that a series of operations will trigger a given number of MySL queries + * + * @param $iExpectedCount Number of MySQL queries that should be executed + * @param callable $oFunction Operations to perform + * + * @throws \MySQLException + * @throws \MySQLQueryHasNoResultException + */ + protected static function assertDBQueryCount($iExpectedCount, callable $oFunction) + { + $iInitialCount = (int) CMDBSource::QueryToScalar("SHOW SESSION STATUS LIKE 'Queries'", 1); + $oFunction(); + $iFinalCount = (int) CMDBSource::QueryToScalar("SHOW SESSION STATUS LIKE 'Queries'", 1); + $iCount = $iFinalCount - 1 - $iInitialCount; + if ($iCount != $iExpectedCount) + { + static::fail("Expected $iExpectedCount queries. $iCount have been executed."); + } + else + { + // Otherwise PHP Unit will consider that no assertion has been made + static::assertTrue(true); + } + } } diff --git a/test/core/DBObjectTest.php b/test/core/DBObjectTest.php index 6c514f72a..9e21de2c4 100644 --- a/test/core/DBObjectTest.php +++ b/test/core/DBObjectTest.php @@ -48,6 +48,7 @@ class DBObjectTest extends ItopDataTestCase /** * Test default page name + * @covers DBObject::GetUIPage */ public function testGetUIPage() { @@ -81,6 +82,9 @@ class DBObjectTest extends ItopDataTestCase array('PHP_INT_MIN', false)); } + /** + * @covers DBObject::GetOriginal + */ public function testGetOriginal() { $oObject = $this->CreateUserRequest(190664); @@ -88,4 +92,114 @@ class DBObjectTest extends ItopDataTestCase static::assertNull($oObject->GetOriginal('sla_tto_passed')); } + /** + * @covers DBObject::NewObject + * @covers DBObject::Get + * @covers DBObject::Set + */ + public function testAttributeRefresh_FriendlyName() + { + $oObject = \MetaModel::NewObject('Person', array('name' => 'Foo', 'first_name' => 'John', 'org_id' => 3, 'location_id' => 2)); + + static::assertEquals('John Foo', $oObject->Get('friendlyname')); + $oObject->Set('name', 'Who'); + static::assertEquals('John Who', $oObject->Get('friendlyname')); + } + + /** + * @covers MetaModel::GetObject + * @covers DBObject::Get + * @covers DBObject::Set + */ + public function testAttributeRefresh_FriendlyNameFromDB() + { + $oObject = \MetaModel::NewObject('Person', array('name' => 'Gary', 'first_name' => 'Romain', 'org_id' => 3, 'location_id' => 2)); + $oObject->DBInsert(); + $iObjKey = $oObject->GetKey(); + + $oObject = \MetaModel::GetObject('Person', $iObjKey); + + static::assertEquals('Romain Gary', $oObject->Get('friendlyname')); + $oObject->Set('name', 'Duris'); + static::assertEquals('Romain Duris', $oObject->Get('friendlyname')); + } + + /** + * @covers DBObject::NewObject + * @covers DBObject::Get + * @covers DBObject::Set + */ + public function testAttributeRefresh_ObsolescenceFlag() + { + $oObject = \MetaModel::NewObject('Person', array('name' => 'Foo', 'first_name' => 'John', 'org_id' => 3, 'location_id' => 2)); + + static::assertEquals(false, (bool)$oObject->Get('obsolescence_flag')); + $oObject->Set('status', 'inactive'); + static::assertEquals(true, (bool)$oObject->Get('obsolescence_flag')); + } + + /** + * @covers DBObject::NewObject + * @covers DBObject::Get + * @covers DBObject::Set + */ + public function testAttributeRefresh_ExternalKeysAndFields() + { + static::assertDBQueryCount(0, function() use (&$oObject){ + $oObject = \MetaModel::NewObject('Person', array('name' => 'Foo', 'first_name' => 'John', 'org_id' => 3, 'location_id' => 2)); + }); + static::assertDBQueryCount(2, function() use (&$oObject){ + static::assertEquals('Demo', $oObject->Get('org_id_friendlyname')); + static::assertEquals('Grenoble', $oObject->Get('location_id_friendlyname')); + }); + + // External key given as an id + static::assertDBQueryCount(1, function() use (&$oObject){ + $oObject->Set('org_id', 2); + static::assertEquals('IT Department', $oObject->Get('org_id_friendlyname')); + }); + + // External key given as an object + static::assertDBQueryCount(1, function() use (&$oBordeaux){ + $oBordeaux = \MetaModel::GetObject('Location', 1); + }); + + static::assertDBQueryCount(0, function() use (&$oBordeaux, &$oObject){ + $oObject->Set('location_id', $oBordeaux); + static::assertEquals('IT Department', $oObject->Get('org_id_friendlyname')); + static::assertEquals('IT Department', $oObject->Get('org_name')); + static::assertEquals('Bordeaux', $oObject->Get('location_id_friendlyname')); + }); + } + + public function testSetExtKeyUnsetDependentAttribute() + { + $oObject = \MetaModel::NewObject('Person', array('name' => 'Foo', 'first_name' => 'John', 'org_id' => 3, 'location_id' => 2)); + $oOrg = \MetaModel::GetObject('Organization', 2); + $oObject->Set('org_id', $oOrg); + static::assertEquals(0, $oObject->Get('location_id')); + } + + /** + * @group Integration + */ + public function testModelExpressions() + { + foreach (\MetaModel::GetClasses() as $sClass) + { + if (\MetaModel::IsAbstract($sClass)) continue; + + $oObject = \MetaModel::NewObject($sClass); + foreach (\MetaModel::ListAttributeDefs($sClass) as $sAttCode => $oAttDef) + { + if ($oAttDef->IsBasedOnOQLExpression()) + { + $this->debug("$sClass::$sAttCode"); + static::assertDBQueryCount(0, function() use (&$oObject, &$oAttDef){ + $oObject->EvaluateExpression($oAttDef->GetOQLExpression()); + }); + } + } + } + } } diff --git a/test/core/ExpressionEvaluateTest.php b/test/core/ExpressionEvaluateTest.php new file mode 100644 index 000000000..79d9bc38b --- /dev/null +++ b/test/core/ExpressionEvaluateTest.php @@ -0,0 +1,535 @@ +GetParameters($sParentFilter); + sort($aExpectedParameters); + sort($aParameters); + static::assertEquals($aExpectedParameters, $aParameters); + } + + public function GetParametersProvider() + { + return array( + array('1 AND 0 OR :hello + :world', null, array('hello', 'world')), + array('1 AND 0 OR :hello + :world', 'this', array()), + array(':this->left + :this->right', null, array('this->left', 'this->right')), + array(':this->left + :this->right', 'this', array('left', 'right')), + array(':this->left + :this->right', 'that', array()), + array(':this_left + :this_right', 'this', array()), + ); + } + + /** + * 100x quicker to execute than testExpressionEvaluate + * + * @covers Expression::Evaluate() + * @covers Expression::FromOQL() + * @relies-on-dataProvider VariousExpressions + * @throws \OQLException + */ + public function _testExpressionEvaluateAllAtOnce() + { + $aTestCases = $this->VariousExpressionsProvider(); + foreach ($aTestCases as $sCaseId => $aTestArgs) + { + $this->debug("Case $sCaseId:"); + $this->testVariousExpressions($aTestArgs[0], $aTestArgs[1]); + } + } + + /** + * @covers Expression::Evaluate() + * @covers Expression::FromOQL() + * @dataProvider VariousExpressionsProvider + * + * @param string $sExpression + * @param string $expectedValue + * + * @throws \OQLException + * @throws \Exception + */ + public function testVariousExpressions($sExpression, $expectedValue) + { + $oExpression = Expression::FromOQL($sExpression); + $value = $oExpression->Evaluate(array()); + static::assertEquals($expectedValue, $value); + } + + public function VariousExpressionsProvider() + { + if (false) + { + $aExpressions = array( + // Test case to isolate for troubleshooting purposes + array('1+1', 2), + ); + } + else + { + $aExpressions = array( + // The bare minimum + array('"blah"', 'blah'), + array('"\\\\"', '\\'), + // Arithmetics + array('2+2', 4), + array('2+2-2', 2), + array('2*(3+4)', 14), + array('(2*3)+4', 10), + array('2*3+4', 10), + // Strings + array("CONCAT('hello', 'world')", 'helloworld'), + // Not yet parsed - array("CONCAT_WS(' ', 'hello', 'world')", 'hello world'), + array("SUBSTR('abcdef', 2, 3)", 'bcd'), + array("TRIM(' Sin dolor ')", 'Sin dolor'), + // Comparison operators + array('1 = 1', 1), + array('1 != 1', 0), + array('0 = 1', 0), + array('0 != 1', 1), + array('2 > 1', 1), + array('2 < 1', 0), + array('1 > 2', 0), + array('2 > 1', 1), + array('2 >= 1', 1), + array('2 >= 2', 1), + array("'the quick brown dog' LIKE '%QUICK%'", 1), + array("'the quick brown dog' LIKE '%SLOW%'", 0), + array("'the quick brown dog' LIKE '%QU_CK%'", 1), + array("'the quick brown dog' LIKE '%QU_ICK%'", 0), + array('"400 (km/h)" LIKE "400%"', 1), + array('"400 (km/h)" LIKE "100%"', 0), + array('"2020-06-12" > "2020-06-11"', 1), + array('"2020-06-12" < "2020-06-11"', 0), + array('" 2020-06-12" > "2020-06-11"', 0), // Leading spaces => a string + array('" 2020-06-12 " > "2020-06-11"', 0), // Trailing spaces => a string + array('"2020-06-12 17:35:13" > "2020-06-12 17:35:12"', 1), + array('"2020-06-12 17:35:13" < "2020-06-12 17:35:12"', 0), + array('"2020-06-12 17:35:13" > "2020-06-12"', 1), + array('"2020-06-12 17:35:13" < "2020-06-12"', 0), + array('"2020-06-12 00:00:00" = "2020-06-12"', 0), + // Logical operators + array('0 AND 0', 0), + array('1 AND 0', 0), + array('0 AND 1', 0), + array('1 AND 1', 1), + array('0 OR 0', 0), + array('0 OR 1', 1), + array('1 OR 0', 1), + array('1 OR 1', 1), + array('1 AND 0 OR 1', 1), + // Casting + array('1 AND "blah"', 0), + array('1 AND "1"', 1), + array('1 AND "2"', 1), + array('1 AND "0"', 0), + array('1 AND "-1"', 1), + // Null + array('NULL', null), + array('1 AND NULL', null), + array('CONCAT("Great but...", NULL)', null), + array('COALESCE(NULL, 123)', 123), + array('COALESCE(321, 123)', 321), + array('ISNULL(NULL)', 1), + array('ISNULL(123)', 0), + // Date functions + array("DATE('2020-03-12 13:18:30')", '2020-03-12'), + array("DATE_FORMAT('2009-10-04 22:23:00', '%Y %m %d %H %i %s')", '2009 10 04 22 23 00'), + array("DATE(NOW()) = CURRENT_DATE()", 1), // Could fail if executed around midnight! + array("TO_DAYS('2020-01-02')", 737791), + array("FROM_DAYS(737791)", '2020-01-02'), + array("YEAR('2020-05-03')", 2020), + array("MONTH('2020-05-03')", 5), + array("DAY('2020-05-03')", 3), + array("DATE_ADD('2020-02-28 18:00:00', INTERVAL 1 HOUR)", '2020-02-28 19:00:00'), + array("DATE_ADD('2020-02-28 18:00:00', INTERVAL 1 DAY)", '2020-02-29 18:00:00'), + array("DATE_SUB('2020-03-01 18:00:00', INTERVAL 1 HOUR)", '2020-03-01 17:00:00'), + array("DATE_SUB('2020-03-01 18:00:00', INTERVAL 1 DAY)", '2020-02-29 18:00:00'), + // Misc. functions + array('IF(1, 123, 567)', 123), + array('IF(0, 123, 567)', 567), + array('ELT(3, "a", "b", "c")', 'c'), + array('ELT(0, "a", "b", "c")', null), + array('ELT(4, "a", "b", "c")', null), + array('INET_ATON("128.0.0.1")', 2147483649), + array('INET_NTOA(2147483649)', '128.0.0.1'), + ); + } + + // Build a comprehensive index + $aRet = array(); + foreach ($aExpressions as $aExp) + { + $aRet[$aExp[0]] = $aExp; + } + return $aRet; + } + + /** + * @covers Expression::Evaluate() + * @dataProvider NotYetParsableExpressionsProvider + * + * @param string $sExpression + * @param string $expectedValue + */ + public function testNotYetParsableExpressions($sExpression, $expectedValue) + { + $sNewExpression = "return $sExpression;"; + $oExpression = eval($sNewExpression); + $res = $oExpression->Evaluate(array()); + static::assertEquals($expectedValue, $res); + } + + public function NotYetParsableExpressionsProvider() + { + $aExpressions = array( + array("new \\FunctionExpression('CONCAT_WS', array(new \\ScalarExpression(' '), new \\ScalarExpression('Hello'), new \ScalarExpression('world!')))", 'Hello world!'), + array("new \\ScalarExpression('windows\\system32')", 'windows\\system32'), + array("new \\BinaryExpression(new \\ScalarExpression('100%'), 'LIKE', new \\ScalarExpression('___\%'))", 1), + array("new \\BinaryExpression(new \ScalarExpression('1000'), 'LIKE', new \ScalarExpression('___\%'))", 0), + // Net yet parsed - array("TIME(NOW()) = CURRENT_TIME()", 1), // Not relevant + // Not yet parsed - array("DATE_ADD('2020-02-28 18:00:00', INTERVAL 1 WEEK)", '2020-03-06 18:00:00'), + // Not yet parsed - array("DATE_SUB('2020-03-01 18:00:00', INTERVAL 1 WEEK)", '2020-02-23 18:00:00'), + // Not yet parsed - array('ROUND(1.2345, 2)', 1.23), + // Not yet parsed - array('FLOOR(1.2)', 1), + ); + // Build a comprehensive index + $aRet = array(); + foreach ($aExpressions as $aExp) + { + $aRet[$aExp[0]] = $aExp; + } + return $aRet; + } + + /** + * Check that the test data would give the same result when evaluated by MySQL + * It uses the data provider ExpressionProvider, and checks every test case in one single query + * + * @throws \MySQLException + */ + public function testMySQLEvaluateAllAtOnce() + { + // Expressions given as an OQL + $aTests = array_values($this->VariousExpressionsProvider()); + + // Expressions given as a PHP statement + foreach (array_values($this->NotYetParsableExpressionsProvider()) as $i => $aTest) + { + $sNewExpression = "return {$aTest[0]};"; + $oExpression = eval($sNewExpression); + $sExpression = $oExpression->RenderExpression(true); + $aTests[] = array($sExpression, $aTest[1]); + } + + $aExpressions = array(); + foreach ($aTests as $i => $aTest) + { + $aExpressions[] = "{$aTest[0]} as test_$i"; + } + + $sSelects = implode(', ', $aExpressions); + $sQuery = "SELECT $sSelects"; + + $this->debug($sQuery); + $aResults = CMDBSource::QueryToArray($sQuery); + + foreach ($aTests as $i => $aTest) + { + $value = $aResults[0]["test_$i"]; + $expectedValue = $aTest[1]; + $this->debug("Test #$i: {$aTests[$i][0]} => ".var_export($value, true)); + static::assertEquals($expectedValue, $value); + } + } + + /** + * @covers DBObject::EvaluateExpression + * @dataProvider ExpressionsWithObjectFieldsProvider + * + * @param $sClass + * @param $aValues + * @param $sExpression + * @param $expected + * + * @throws \CoreException + * @throws \OQLException + */ + public function testExpressionsWithObjectFields($sClass, $aValues, $sExpression, $expected) + { + $oObject = MetaModel::NewObject($sClass, $aValues); + $oExpression = Expression::FromOQL($sExpression); + + $res = $oObject->EvaluateExpression($oExpression); + + static::assertEquals($expected, $res); + } + + public function ExpressionsWithObjectFieldsProvider() + { + return array( + array('Location', array('name' => 'Grenoble', 'org_id' => 2), 'org_id', 2), + array('Location', array('name' => 'Grenoble', 'org_id' => 2), 'CONCAT(SUBSTR(name, 4), " cause")', 'noble cause'), + ); + } + + /** + * @dataProvider ExpressionWithParametersProvider + * + * @param $sExpression + * @param $aParameters + * @param $expected + * + * @throws \OQLException + * @throws \Exception + */ + public function testExpressionWithParameters($sExpression, $aParameters, $expected) + { + $oExpression = Expression::FromOQL($sExpression); + $res = $oExpression->Evaluate($aParameters); + static::assertEquals($expected, $res); + } + + public function ExpressionWithParametersProvider() + { + return array( + array('CONCAT(SUBSTR(name, 4), " cause")', array('name' => 'noble'), 'le cause'), + ); + } + + /** + * Check Expression::IfTrue + * + * @covers Expression::FromOQL + * @covers Expression::IsTrue + * @dataProvider TrueExpressionsProvider + * + * @param $sExpression + * @param $bExpectTrue + * + * @throws \OQLException + */ + public function testTrueExpressions($sExpression, $bExpectTrue) + { + $oExpression = Expression::FromOQL($sExpression); + + $res = $oExpression->IsTrue(); + if ($bExpectTrue) + { + static::assertTrue($res, 'arg: '.$sExpression); + } + else + { + static::assertFalse($res, 'arg: '.$sExpression); + } + } + + public function TrueExpressionsProvider() + { + $aExpressions = array( + array('1', true), + array('0 OR 0', false), + array('1 AND 1', true), + array('1 AND (1 OR 0)', true) + ); + // Build a comprehensive index + $aRet = array(); + foreach ($aExpressions as $aExp) + { + $aRet[$aExp[0]] = $aExp; + } + return $aRet; + } + + /** + * @covers FunctionExpression::Evaluate() + * @dataProvider TimeFormatsProvider + * + * @param $sFormat + * @param $bProcessed + * @param $sValueOrException + * + * @throws \CoreException + * @throws \MySQLException + * @throws \MySQLQueryHasNoResultException + * @throws \Exception + */ + public function testTimeFormat($sFormat, $bProcessed, $sValueOrException) + { + $sDate = '2009-06-04 21:23:24'; + $oExpression = new FunctionExpression('DATE_FORMAT', array(new ScalarExpression($sDate), new ScalarExpression("%$sFormat"))); + if ($bProcessed) + { + $sqlValue = CMDBSource::QueryToScalar("SELECT DATE_FORMAT('$sDate', '%$sFormat')"); + static::assertEquals($sqlValue, $sValueOrException, 'Check test against MySQL'); + + $res = $oExpression->Evaluate(array()); + static::assertEquals($sValueOrException, $res, 'Check evaluation'); + } + else + { + static::expectException($sValueOrException); + $oExpression->Evaluate(array()); + } + } + + public function TimeFormatsProvider() + { + $aTests = array( + array('a', true, 'Thu'), + array('b', true, 'Jun'), + array('c', true, '6'), + array('D', true, '4th'), + array('d', true, '04'), + array('e', true, '4'), + array('f', false, 'NotYetEvaluatedExpression'), // microseconds: no way! + array('H', true, '21'), + array('h', true, '09'), + array('I', true, '09'), + array('i', true, '23'), + array('j', true, '155'), // day of the year + array('k', true, '21'), + array('l', true, '9'), + array('M', true, 'June'), + array('m', true, '06'), + array('p', true, 'PM'), + array('r', true, '09:23:24 PM'), + array('S', true, '24'), + array('s', true, '24'), + array('T', true, '21:23:24'), + array('U', false, 'NotYetEvaluatedExpression'), // Week sunday based (mode 0) + array('u', false, 'NotYetEvaluatedExpression'), // Week monday based (mode 1) + array('V', false, 'NotYetEvaluatedExpression'), // Week sunday based (mode 2) + array('v', true, '23'), // Week monday based (mode 3 - ISO-8601) + array('W', true, 'Thursday'), + array('w', true, '4'), + array('X', false, 'NotYetEvaluatedExpression'), + array('x', true, '2009'), // to be used with %v (ISO - 8601) + array('Y', true, '2009'), + array('y', true, '09'), + ); + $aRes = array(); + foreach ($aTests as $aTest) + { + $aRes["Format %{$aTest[0]}"] = $aTest; + } + return $aRes; + } + + /** + * Systematically check all supported format specs, for a given date + * + * @covers FunctionExpression::Evaluate() + * @dataProvider EveryTimeFormatProvider + * + * @param $sDate + * + * @throws \CoreException + * @throws \MySQLException + * @throws \Exception + */ + public function testEveryTimeFormat($sDate) + { + $aFormats = $this->TimeFormatsProvider(); + $aSelects = array(); + foreach ($aFormats as $sFormatDesc => $aFormatSpec) + { + $sFormat = $aFormatSpec[0]; + $bProcessed = $aFormatSpec[1]; + if ($bProcessed) + { + $aSelects["%$sFormat"] = "DATE_FORMAT('$sDate', '%$sFormat') AS `$sFormat`"; + } + } + $sSelects = "SELECT ".implode(', ', $aSelects); + $aRes = CMDBSource::QueryToArray($sSelects); + $aRow = $aRes[0]; + foreach ($aFormats as $sFormatDesc => $aFormatSpec) + { + $sFormat = $aFormatSpec[0]; + $bProcessed = $aFormatSpec[1]; + if ($bProcessed) + { + $oExpression = new FunctionExpression('DATE_FORMAT', array(new ScalarExpression($sDate), new ScalarExpression("%$sFormat"))); + $res = $oExpression->Evaluate(array()); + static::assertEquals($aRow[$sFormat], $res, "Format %$sFormat not matching MySQL for '$sDate'"); + } + } + } + public function EveryTimeFormatProvider() + { + return array( + array('1971-07-19 8:40:00'), + array('1999-12-31 23:59:59'), + array('2000-01-01 00:00:00'), + array('2009-06-04 21:23:24'), + array('2020-02-29 23:59:59'), + array('2030-10-21 23:59:59'), + array('2050-12-21 23:59:59'), + ); + } + + /** + * Systematically check all supported format specs, for a range of dates + * + * @covers FunctionExpression::Evaluate() + * @dataProvider EveryTimeFormatOnDateRangeProvider + * + * @param $sStartDate + * @param $sInterval + * @param $iRepeat + * + * @throws \CoreException + * @throws \MySQLException + * @throws \Exception + */ + public function testEveryTimeFormatOnDateRange($sStartDate, $sInterval, $iRepeat) + { + $oDate = new DateTime($sStartDate); + for ($i = 0 ; $i < $iRepeat ; $i++) + { + $sDate = date_format($oDate, 'Y-m-d, H:i:s'); + $this->debug("Checking '$sDate'"); + $this->testEveryTimeFormat($sDate); + $oDate->add(new DateInterval($sInterval)); + } + } + + public function EveryTimeFormatOnDateRangeProvider() + { + return array( + '10 years, day by day' => array('2000-01-01', 'P1D', 365 * 10), + '1 day, hour by hour' => array('2000-01-01 00:01:02', 'PT1H', 24), + '1 hour, minute by minute' => array('2000-01-01 00:01:02', 'PT1M', 60), + '1 minute, second by second' => array('2000-01-01 00:01:02', 'PT1S', 60), + ); + } +} \ No newline at end of file diff --git a/test/core/MetaModelTest.php b/test/core/MetaModelTest.php index c0bd95fa9..3ec4e47f9 100644 --- a/test/core/MetaModelTest.php +++ b/test/core/MetaModelTest.php @@ -83,4 +83,100 @@ class MetaModelTest extends ItopDataTestCase ), ); } + + /** + * @covers MetaModel::GetDependentAttributes() + * @dataProvider GetDependentAttributesProvider + * + * @param string $sClass + * @param string $sAttCode + * @param array $aExpectedAttCodes + * + * @throws \Exception + */ + public function testGetDependentAttributes($sClass, $sAttCode, array $aExpectedAttCodes) + { + $aRes = MetaModel::GetDependentAttributes($sClass, $sAttCode); + // The order doesn't matter + sort($aRes); + sort($aExpectedAttCodes); + static::assertEquals($aExpectedAttCodes, $aRes); + } + + public function GetDependentAttributesProvider() + { + $aRawCases = array( + array('Person', 'org_id', array('location_id', 'org_name', 'org_id_friendlyname', 'org_id_obsolescence_flag')), + array('Person', 'name', array('friendlyname')), + array('Person', 'status', array('obsolescence_flag')), + ); + $aRet = array(); + foreach ($aRawCases as $i => $aData) + { + $aRet[$aData[0].'::'.$aData[1]] = $aData; + } + return $aRet; + } + + /** + * @covers MetaModel::GetPrerequisiteAttributes() + * @dataProvider GetPrerequisiteAttributesProvider + * + * @param string $sClass + * @param string $sAttCode + * @param array $aExpectedAttCodes + * + * @throws \Exception + */ + public function testGetPrerequisiteAttributes($sClass, $sAttCode, array $aExpectedAttCodes) + { + $aRes = MetaModel::GetPrerequisiteAttributes($sClass, $sAttCode); + // The order doesn't matter + sort($aRes); + sort($aExpectedAttCodes); + static::assertEquals($aRes, $aExpectedAttCodes); + } + + public function GetPrerequisiteAttributesProvider() + { + $aRawCases = array( + array('Person', 'friendlyname', array('name', 'first_name')), + array('Person', 'obsolescence_flag', array('status')), + array('Person', 'org_id_friendlyname', array('org_id')), + array('Person', 'org_id', array()), + array('Person', 'org_name', array('org_id')), + ); + $aRet = array(); + foreach ($aRawCases as $i => $aData) + { + $aRet[$aData[0].'::'.$aData[1]] = $aData; + } + return $aRet; + } + + /** + * To be removed as soon as the dependencies on external fields are obsoleted + * @Group Integration + */ + public function testManualVersusAutomaticDependenciesOnExtKeys() + { + foreach (\MetaModel::GetClasses() as $sClass) + { + if (\MetaModel::IsAbstract($sClass)) continue; + + foreach (\MetaModel::ListAttributeDefs($sClass) as $sAttCode => $oAttDef) + { + if (\MetaModel::GetAttributeOrigin($sClass, $sAttCode) != $sClass) continue; + if (!$oAttDef instanceof \AttributeExternalKey) continue; + + $aManual = $oAttDef->Get('depends_on'); + $aAuto = \MetaModel::GetPrerequisiteAttributes($sClass, $sAttCode); + // The order doesn't matter + sort($aAuto); + sort($aManual); + static::assertEquals($aManual, $aAuto, "Class: $sClass, Attribute: $sAttCode"); + } + } + } + }