From 4ee78ea59cd2f35d4d4ee0bfd1e0da5a3fe8ca5d Mon Sep 17 00:00:00 2001 From: Denis Flaven Date: Tue, 7 Jul 2015 13:01:40 +0000 Subject: [PATCH] #1078: Properly record the history of LinkedSet(Indirect) SVN:trunk[3626] --- core/attributedef.class.inc.php | 69 +++++++- core/dbobject.class.php | 133 +++++++++------ core/dbobjectset.class.php | 278 ++++++++++++++++++++++++-------- 3 files changed, 365 insertions(+), 115 deletions(-) diff --git a/core/attributedef.class.inc.php b/core/attributedef.class.inc.php index 76f61d1b3..7ac55c71a 100644 --- a/core/attributedef.class.inc.php +++ b/core/attributedef.class.inc.php @@ -621,6 +621,27 @@ abstract class AttributeDefinition $oNewCondition = new BinaryExpression($oField, $sSQLOperator, $oRightExpr); return $oNewCondition; } + + /** + * Tells if an attribute is part of the unique fingerprint of the object (used for comparing two objects) + * All attributes which value is not based on a value from the object itself (like ExternalFields or LinkedSet) + * must be excluded from the object's signature + * @return boolean + */ + public function IsPartOfFingerprint() + { + return true; + } + + /** + * The part of the current attribute in the object's signature, for the supplied value + * @param unknown $value The value of this attribute for the object + * @return string The "signature" for this field/attribute + */ + public function Fingerprint($value) + { + return (string)$value; + } } /** @@ -1078,7 +1099,7 @@ class AttributeLinkedSet extends AttributeDefinition } // Both values are Object sets - return $val1->HasSameContents($val2); + return $val1->HasSameContents($val2, array($this->GetExtKeyToMe())); } /** @@ -1091,6 +1112,8 @@ class AttributeLinkedSet extends AttributeDefinition $oRemoteAtt = MetaModel::GetAttributeDef($this->GetLinkedClass(), $this->GetExtKeyToMe()); return $oRemoteAtt; } + + public function IsPartOfFingerprint() { return false; } } /** @@ -1918,6 +1941,8 @@ class AttributePassword extends AttributeString return '******'; } } + + public function IsPartOfFingerprint() { return false; } // Cannot reliably compare two encrypted passwords since the same password will be encrypted in diffferent manners depending on the random 'salt' } /** @@ -2447,6 +2472,16 @@ class AttributeCaseLog extends AttributeLongText } return $ret; } + + public function Fingerprint($value) + { + $sFingerprint = ''; + if ($value instanceOf ormCaseLog) + { + $sFingerprint = $value->GetText(); + } + return $sFingerprint; + } } /** @@ -3720,6 +3755,8 @@ class AttributeExternalField extends AttributeDefinition $oExtAttDef = $this->GetExtAttDef(); return $oExtAttDef->GetAsCSV($value, $sSeparator, $sTestQualifier, null, $bLocalize); } + + public function IsPartOfFingerprint() { return false; } } /** @@ -4004,6 +4041,16 @@ class AttributeBlob extends AttributeDefinition } return $value; } + + public function Fingerprint($value) + { + $sFingerprint = ''; + if ($value instanceOf ormDocument) + { + $sFingerprint = md5($value->GetData()); + } + return $sFingerprint; + } } /** @@ -4249,6 +4296,16 @@ class AttributeStopWatch extends AttributeDefinition { return $this->Get('thresholds'); } + + public function Fingerprint($value) + { + $sFingerprint = ''; + if (is_object($value)) + { + $sFingerprint = $value->GetAsHTML($this); + } + return $sFingerprint; + } /** * To expose internal values: Declare an attribute AttributeSubItem @@ -4720,6 +4777,8 @@ class AttributeSubItem extends AttributeDefinition $res = $oParent->GetSubItemAsEditValue($this->Get('item_code'), $value); return $res; } + + public function IsPartOfFingerprint() { return false; } } /** @@ -5165,7 +5224,9 @@ class AttributeComputedFieldVoid extends AttributeDefinition default: return $this->GetSQLExpr()." = $sQValue"; } - } + } + + public function IsPartOfFingerprint() { return false; } } /** @@ -5296,7 +5357,9 @@ class AttributeFriendlyName extends AttributeComputedFieldVoid default: return $this->GetSQLExpr()." LIKE $sQValue"; } - } + } + + public function IsPartOfFingerprint() { return false; } } /** diff --git a/core/dbobject.class.php b/core/dbobject.class.php index 8baa7b996..beb546d69 100644 --- a/core/dbobject.class.php +++ b/core/dbobject.class.php @@ -91,7 +91,8 @@ abstract class DBObject implements iDisplay private $m_bFullyLoaded = false; // Compound objects can be partially loaded private $m_aLoadedAtt = array(); // Compound objects can be partially loaded, array of sAttCode - protected $m_aModifiedAtt = array(); // list of (potentially) modified sAttCodes + protected $m_aTouchedAtt = array(); // list of (potentially) modified sAttCodes + protected $m_aModifiedAtt = array(); // real modification status: for each attCode can be: unset => don't know, true => modified, false => not modified (the same value as the original value was set) protected $m_aSynchroData = null; // Set of Synch data related to this object protected $m_sHighlightCode = null; protected $m_aCallbacks = array(); @@ -103,6 +104,7 @@ abstract class DBObject implements iDisplay { $this->FromRow($aRow, $sClassAlias, $aAttToLoad, $aExtendedDataSpec); $this->m_bFullyLoaded = $this->IsFullyLoaded(); + $this->m_aTouchedAtt = array(); $this->m_aModifiedAtt = array(); return; } @@ -228,6 +230,7 @@ abstract class DBObject implements iDisplay } $this->m_bFullyLoaded = true; + $this->m_aTouchedAtt = array(); $this->m_aModifiedAtt = array(); } @@ -407,8 +410,9 @@ abstract class DBObject implements iDisplay $realvalue = $oAttDef->MakeRealValue($value, $this); $this->m_aCurrValues[$sAttCode] = $realvalue; - $this->m_aModifiedAtt[$sAttCode] = true; - + $this->m_aTouchedAtt[$sAttCode] = true; + unset($this->m_aModifiedAtt[$sAttCode]); + // The object has changed, reset caches $this->m_bCheckStatus = null; @@ -1240,18 +1244,29 @@ abstract class DBObject implements iDisplay // The value was not set $aDelta[$sAtt] = $proposedValue; } - elseif(!array_key_exists($sAtt, $this->m_aModifiedAtt)) + elseif(!array_key_exists($sAtt, $this->m_aTouchedAtt) || (array_key_exists($sAtt, $this->m_aModifiedAtt) && $this->m_aModifiedAtt[$sAtt] == false)) { - // This attCode was never set, canno tbe modified + // This attCode was never set, cannot be modified + // or the same value - as the original value - was set, and has been verified as equivalent to the original value continue; } + else if (array_key_exists($sAtt, $this->m_aModifiedAtt) && $this->m_aModifiedAtt[$sAtt] == true) + { + // We already know that the value is really modified + $aDelta[$sAtt] = $proposedValue; + } elseif(is_object($proposedValue)) { - $oLinkAttDef = MetaModel::GetAttributeDef(get_class($this), $sAtt); + $oAttDef = MetaModel::GetAttributeDef(get_class($this), $sAtt); // The value is an object, the comparison is not strict - if (!$oLinkAttDef->Equals($proposedValue, $this->m_aOrigValues[$sAtt])) + if (!$oAttDef->Equals($proposedValue, $this->m_aOrigValues[$sAtt])) { $aDelta[$sAtt] = $proposedValue; + $this->m_aModifiedAtt[$sAtt] = true; // Really modified + } + else + { + $this->m_aModifiedAtt[$sAtt] = false; // Not really modified } } else @@ -1264,6 +1279,11 @@ abstract class DBObject implements iDisplay //var_dump($proposedValue); //echo "\n"; $aDelta[$sAtt] = $proposedValue; + $this->m_aModifiedAtt[$sAtt] = true; // Really modified + } + else + { + $this->m_aModifiedAtt[$sAtt] = false; // Not really modified } } } @@ -1332,49 +1352,44 @@ abstract class DBObject implements iDisplay foreach(MetaModel::ListAttributeDefs(get_class($this)) as $sAttCode=>$oAttDef) { if (!$oAttDef->IsLinkSet()) continue; - if (!array_key_exists($sAttCode, $this->m_aModifiedAtt)) continue; - - $oOriginalSet = $this->m_aOrigValues[$sAttCode]; - if ($oOriginalSet != null) - { - $aOriginalList = $oOriginalSet->ToArray(); - } - else - { - $aOriginalList = array(); - } - - $oLinks = $this->Get($sAttCode); - $oLinks->Rewind(); - while ($oLinkedObject = $oLinks->Fetch()) - { - if (!array_key_exists($oLinkedObject->GetKey(), $aOriginalList)) - { - // New object added to the set, make it point properly - $oLinkedObject->Set($oAttDef->GetExtKeyToMe(), $this->m_iKey); - } - if ($oLinkedObject->IsModified()) - { - // Objects can be modified because: - // 1) They've just been added into the set, so their ExtKey is modified - // 2) They are about to be removed from the set BUT NOT deleted, their ExtKey has been reset - $oLinkedObject->DBWrite(); - } - } - - // Delete the objects that were initialy present and disappeared from the list - // (if any) - if (count($aOriginalList) > 0) - { - $aNewSet = $oLinks->ToArray(); + if (!array_key_exists($sAttCode, $this->m_aTouchedAtt)) continue; + if (array_key_exists($sAttCode, $this->m_aModifiedAtt) && ($this->m_aModifiedAtt[$sAttCode] == false)) continue; - foreach($aOriginalList as $iId => $oObject) + $sExtKeyToMe = $oAttDef->GetExtKeyToMe(); + $sAdditionalKey = null; + if ($oAttDef->IsIndirect()) + { + $sAdditionalKey = $oAttDef->GetExtKeyToRemote(); + } + $oComparator = new DBObjectSetComparator($this->m_aOrigValues[$sAttCode], $this->Get($sAttCode), array($sExtKeyToMe), $sAdditionalKey); + $aChanges = $oComparator->GetDifferences(); + + foreach($aChanges['added'] as $oLink) + { + // Make sure that the objects in the set point to "this" + $oLink->Set($oAttDef->GetExtKeyToMe(), $this->m_iKey); + $id = $oLink->DBWrite(); + } + + foreach($aChanges['modified'] as $oLink) + { + // Make sure that the objects in the set point to "this" + $oLink->Set($oAttDef->GetExtKeyToMe(), $this->m_iKey); + $oLink->DBWrite(); + } + + foreach($aChanges['removed'] as $oLink) + { + // Objects can be removed from the set because: + // 1) They should no longer exist + // 2) They are about to be removed from the set BUT NOT deleted, their ExtKey has been reset + if ($oLink->IsModified() && ($oLink->Get($sExtKeyToMe) != $this->m_iKey)) { - if (!array_key_exists($iId, $aNewSet)) - { - // It disappeared from the list - $oObject->DBDelete(); - } + $oLink->DBWrite(); + } + else + { + $oLink->DBDelete(); } } } @@ -2971,5 +2986,27 @@ abstract class DBObject implements iDisplay 'params' => $aParameters ); } + + /** + * Computes a text-like fingerprint identifying the content of the object + * but excluding the specified columns + * @param $aExcludedColumns array The list of columns to exclude + * @return string + */ + public function Fingerprint($aExcludedColumns = array()) + { + $sFingerprint = ''; + foreach(MetaModel::ListAttributeDefs(get_class($this)) as $sAttCode => $oAttDef) + { + if (!in_array($sAttCode, $aExcludedColumns)) + { + if ($oAttDef->IsPartOfFingerprint()) + { + $sFingerprint .= chr(0).$oAttDef->Fingerprint($this->Get($sAttCode)); + } + } + } + return $sFingerprint; + } } diff --git a/core/dbobjectset.class.php b/core/dbobjectset.class.php index a2d4326c8..11b338e92 100644 --- a/core/dbobjectset.class.php +++ b/core/dbobjectset.class.php @@ -837,74 +837,16 @@ class DBObjectSet * Compare two sets of objects to determine if their content is identical or not. * * Limitation: - * Works only on objects written to the DB, since we rely on their identifiers + * Works only for sets of 1 column (i.e. one class of object selected) * * @param DBObjectSet $oObjectSet + * @param array $aExcludeColumns The list of columns to exclude frop the comparison * @return boolean True if the sets are identical, false otherwise */ - public function HasSameContents(DBObjectSet $oObjectSet) - { - if ($this->GetRootClass() != $oObjectSet->GetRootClass()) - { - return false; - } - if ($this->Count() != $oObjectSet->Count()) - { - return false; - } - - $aId2Row = array(); - $bRet = true; - $iCurrPos = $this->m_iCurrRow; // Save the cursor - $idx = 0; - - // Optimization: we retain the first $iMaxObjects objects in memory - // to speed up the comparison of small sets (see below: $oObject->Equals($oSibling)) - $iMaxObjects = 20; - $aCachedObjects = array(); - while($oObj = $this->Fetch()) - { - $aId2Row[$oObj->GetKey()] = $idx; - if ($idx <= $iMaxObjects) - { - $aCachedObjects[$idx] = $oObj; - } - $idx++; - } - - $oObjectSet->Rewind(); - while ($oObject = $oObjectSet->Fetch()) - { - $iObjectKey = $oObject->GetKey(); - if ($iObjectKey < 0) - { - $bRet = false; - break; - } - if (!array_key_exists($iObjectKey, $aId2Row)) - { - $bRet = false; - break; - } - $iRow = $aId2Row[$iObjectKey]; - if (array_key_exists($iRow, $aCachedObjects)) - { - // Cache hit - $oSibling = $aCachedObjects[$iRow]; - } - else - { - // Go fetch it from the DB, unless it's an object added in-memory - $oSibling = $this->GetObjectAt($iRow); - } - if (!$oObject->Equals($oSibling)) - { - $bRet = false; - break; - } - } - $this->Seek($iCurrPos); // Restore the cursor - return $bRet; + public function HasSameContents(DBObjectSet $oObjectSet, $aExcludeColumns = array()) + { + $oComparator = new DBObjectSetComparator($this, $oObjectSet, $aExcludeColumns); + return $oComparator->SetsAreEquivalent(); } protected function GetObjectAt($iIndex) @@ -1195,3 +1137,211 @@ function HashCountComparison($a, $b) // Sort descending on 'count' } return ($a['count'] > $b['count']) ? -1 : 1; } + +/** + * Helper class to compare the content of two DBObjectSets based on the fingerprints of the contained objects + * When computing the actual differences, the algorithm tries to preserve as much as possible the existing + * objects (i.e. prefers 'modified' to 'removed' + 'added') + * + * LIMITATION: only DBObjectSets with one column (i.e. one class of object selected) are supported + */ +class DBObjectSetComparator +{ + protected $aFingerprints1; + protected $aFingerprints2; + protected $aIDs1; + protected $aIDs2; + protected $aExcludedColumns; + protected $oSet1; + protected $oSet2; + protected $sAdditionalKeyColumn; + protected $aAdditionalKeys; + + /** + * Initializes the comparator + * @param DBObjectSet $oSet1 The first set of objects to compare, or null + * @param DBObjectSet $oSet2 The second set of objects to compare, or null + * @param array $aExcludedColumns The list of columns (= attribute codes) to exclude from the comparison + * @param string $sAdditionalKeyColumn The attribute code of an additional column to be considered as a key indentifying the object (useful for n:n links) + */ + public function __construct($oSet1, $oSet2, $aExcludedColumns = array(), $sAdditionalKeyColumn = null) + { + $this->aFingerprints1 = null; + $this->aFingerprints2 = null; + $this->aIDs1 = array(); + $this->aIDs2 = array(); + $this->aExcludedColumns = $aExcludedColumns; + $this->sAdditionalKeyColumn = $sAdditionalKeyColumn; + $this->aAdditionalKeys = null; + $this->oSet1 = $oSet1; + $this->oSet2 = $oSet2; + } + + /** + * Builds the lists of fingerprints and initializes internal structures, if it was not already done + */ + protected function ComputeFingerprints() + { + if ($this->aFingerprints1 === null) + { + $this->aFingerprints1 = array(); + $this->aFingerprints2 = array(); + $this->aAdditionalKeys = array(); + + if ($this->oSet1 !== null) + { + $aAliases = $this->oSet1->GetSelectedClasses(); + if (count($aAliases) > 1) throw new Exception('DBObjectSetComparator does not support Sets with more than one column. $oSet1: ('.print_r($aAliases, true).')'); + + $this->oSet1->Rewind(); + while($oObj = $this->oSet1->Fetch()) + { + $sFingerprint = $oObj->Fingerprint($this->aExcludedColumns); + $this->aFingerprints1[$sFingerprint] = $oObj; + if (!$oObj->IsNew()) + { + $this->aIDs1[$oObj->GetKey()] = $oObj; + } + } + $this->oSet1->Rewind(); + } + + if ($this->oSet2 !== null) + { + $aAliases = $this->oSet2->GetSelectedClasses(); + if (count($aAliases) > 1) throw new Exception('DBObjectSetComparator does not support Sets with more than one column. $oSet2: ('.print_r($aAliases, true).')'); + + $this->oSet2->Rewind(); + while($oObj = $this->oSet2->Fetch()) + { + $sFingerprint = $oObj->Fingerprint($this->aExcludedColumns); + $this->aFingerprints2[$sFingerprint] = $oObj; + if (!$oObj->IsNew()) + { + $this->aIDs2[$oObj->GetKey()] = $oObj; + } + + if ($this->sAdditionalKeyColumn !== null) + { + $this->aAdditionalKeys[$oObj->Get($this->sAdditionalKeyColumn)] = $oObj; + } + } + $this->oSet2->Rewind(); + } + } + } + + /** + * Tells if the sets are equivalent or not. Returns as soon as the first difference is found. + * @return boolean true if the set have an equivalent content, false otherwise + */ + public function SetsAreEquivalent() + { + if (($this->oSet1 === null) && ($this->oSet2 === null)) + { + // Both sets are empty, they are equal + return true; + } + else if (($this->oSet1 === null) || ($this->oSet2 === null)) + { + // one of them is empty, they are different + return false; + } + + if (($this->oSet1->GetRootClass() != $this->oSet2->GetRootClass()) || ($this->oSet1->Count() != $this->oSet2->Count())) return false; + + $this->ComputeFingerprints(); + + // Check that all objects in Set1 are also in Set2 + foreach($this->aFingerprints1 as $sFingerprint => $oObj) + { + if (!array_key_exists($sFingerprint, $this->aFingerprints2)) + { + return false; + } + } + + // Vice versa + // Check that all objects in Set2 are also in Set1 + foreach($this->aFingerprints2 as $sFingerprint => $oObj) + { + if (!array_key_exists($sFingerprint, $this->aFingerprints1)) + { + return false; + } + } + + return true; + } + + /** + * Get the list of differences between the two sets. + * Returns a hash: 'added' => DBObject(s), 'removed' => DBObject(s), 'modified' => DBObjects(s) + * @return Ambigous + */ + public function GetDifferences() + { + $aResult = array('added' => array(), 'removed' => array(), 'modified' => array()); + $this->ComputeFingerprints(); + + // Check that all objects in Set1 are also in Set2 + foreach($this->aFingerprints1 as $sFingerprint => $oObj) + { + if (array_key_exists($oObj->GetKey(), $this->aIDs2) && ($this->aIDs2[$oObj->GetKey()]->IsModified())) + { + // The very same object exists in both set, but was modified since its load + $aResult['modified'][$oObj->GetKey()] = $this->aIDs2[$oObj->GetKey()]; + } + else if (($this->sAdditionalKeyColumn !== null) && array_key_exists($oObj->Get($this->sAdditionalKeyColumn), $this->aAdditionalKeys)) + { + // Special case for n:n links where the link is recreated between the very same 2 objects, but some of its attributes are modified + // Let's consider this as a "modification" instead of "deletion" + "creation" in order to have a "clean" history for the objects + $oDestObj = $this->aAdditionalKeys[$oObj->Get($this->sAdditionalKeyColumn)]; + $oCloneObj = $this->CopyFrom($oObj, $oDestObj); + $aResult['modified'][$oObj->GetKey()] = $oCloneObj; + // Mark this as processed, so that the pass on aFingerprints2 below ignores this object + $sNewFingerprint = $oDestObj->Fingerprint($this->aExcludedColumns); + $this->aFingerprints2[$sNewFingerprint] = $oCloneObj; + } + else if (!array_key_exists($sFingerprint, $this->aFingerprints2)) + { + $aResult['removed'][] = $oObj; + } + } + + // Vice versa + // Check that all objects in Set2 are also in Set1 + foreach($this->aFingerprints2 as $sFingerprint => $oObj) + { + if (array_key_exists($oObj->GetKey(), $this->aIDs1) && ($oObj->IsModified())) + { + // Already marked as modified above + //$aResult['modified'][$oObj->GetKey()] = $oObj; + } + else if (!array_key_exists($sFingerprint, $this->aFingerprints1) && $oObj->IsNew()) + { + $aResult['added'][] = $oObj; + } + } + return $aResult; + } + + /** + * Helpr to clone (in memory) an object and to apply to it the values taken from a second object + * @param DBObject $oObjToClone + * @param DBObject $oObjWithValues + * @return DBObject The modified clone + */ + protected function CopyFrom($oObjToClone, $oObjWithValues) + { + $oObj = MetaModel::GetObject(get_class($oObjToClone), $oObjToClone->GetKey()); + foreach(MetaModel::ListAttributeDefs(get_class($oObj)) as $sAttCode => $oAttDef) + { + if (!in_array($sAttCode, $this->aExcludedColumns) && $oAttDef->IsWritable()) + { + $oObj->Set($sAttCode, $oObjWithValues->Get($sAttCode)); + } + } + return $oObj; + } +} \ No newline at end of file