Bläddra i källkod

#1078: Properly record the history of LinkedSet(Indirect)

git-svn-id: http://svn.code.sf.net/p/itop/code/trunk@3626 a333f486-631f-4898-b8df-5754b55c2be0
dflaven 10 år sedan
förälder
incheckning
af849fbd7f
3 ändrade filer med 357 tillägg och 107 borttagningar
  1. 66 3
      core/attributedef.class.inc.php
  2. 77 40
      core/dbobject.class.php
  3. 214 64
      core/dbobjectset.class.php

+ 66 - 3
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; }
 }
 
 /**

+ 77 - 40
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 "</pre>\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;
+			if (!array_key_exists($sAttCode, $this->m_aTouchedAtt)) continue;
+			if (array_key_exists($sAttCode, $this->m_aModifiedAtt) && ($this->m_aModifiedAtt[$sAttCode] == false)) continue;
+				
+			$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();
 			
-			$oOriginalSet = $this->m_aOrigValues[$sAttCode];
-			if ($oOriginalSet != null)
+			foreach($aChanges['added'] as $oLink)
 			{
-				$aOriginalList = $oOriginalSet->ToArray();
+				// Make sure that the objects in the set point to "this"
+				$oLink->Set($oAttDef->GetExtKeyToMe(), $this->m_iKey);
+				$id = $oLink->DBWrite();
 			}
-			else
+			
+			foreach($aChanges['modified'] as $oLink)
 			{
-				$aOriginalList = array();
+				// Make sure that the objects in the set point to "this"
+				$oLink->Set($oAttDef->GetExtKeyToMe(), $this->m_iKey);
+				$oLink->DBWrite();
 			}
 			
-			$oLinks = $this->Get($sAttCode);
-			$oLinks->Rewind();
-			while ($oLinkedObject = $oLinks->Fetch())
+			foreach($aChanges['removed'] as $oLink)
 			{
-				if (!array_key_exists($oLinkedObject->GetKey(), $aOriginalList))
+				// 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))
 				{
-					// New object added to the set, make it point properly
-					$oLinkedObject->Set($oAttDef->GetExtKeyToMe(), $this->m_iKey);
+					$oLink->DBWrite();
 				}
-				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();
-				
-				foreach($aOriginalList as $iId => $oObject)
+				else
 				{
-					if (!array_key_exists($iId, $aNewSet))
-					{
-						// It disappeared from the list
-						$oObject->DBDelete();
-					}
+					$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;
+	}
 }
 

+ 214 - 64
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 <int:DBObject: , unknown>
+	 */
+	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;
+	}
+}