diff --git a/core/csvparser.class.inc.php b/core/csvparser.class.inc.php index 75b7b16a9..ec2e4955e 100644 --- a/core/csvparser.class.inc.php +++ b/core/csvparser.class.inc.php @@ -40,6 +40,8 @@ define('evTEXTQUAL', 3); // used for escaping as well define('evOTHERCHAR', 4); define('evEND', 5); +define('NULL_VALUE', ''); + /** * CSVParser @@ -82,6 +84,10 @@ class CSVParser { $sCell = $this->m_sCurrCell; } + if ($sCell == NULL_VALUE) + { + $sCell = null; + } if (!is_null($aFieldMap)) { diff --git a/core/metamodel.class.php b/core/metamodel.class.php index 563bac104..68de1a208 100644 --- a/core/metamodel.class.php +++ b/core/metamodel.class.php @@ -3730,6 +3730,31 @@ abstract class MetaModel return $oObj; } + static protected $m_aCacheObjectByColumn = array(); + + public static function GetObjectByColumn($sClass, $sAttCode, $value, $bMustBeFoundUnique = true) + { + if (!isset(self::$m_aCacheObjectByColumn[$sClass][$sAttCode][$value])) + { + self::_check_subclass($sClass); + + $oObjSearch = new DBObjectSearch($sClass); + $oObjSearch->AddCondition($sAttCode, $value, '='); + $oSet = new DBObjectSet($oObjSearch); + if ($oSet->Count() == 1) + { + self::$m_aCacheObjectByColumn[$sClass][$sAttCode][$value] = $oSet->fetch(); + } + else + { + if ($bMustBeFoundUnique) throw new CoreException('Failed to get an object by column', array('class'=>$sClass, 'attcode'=>$sAttCode, 'value'=>$value, 'matches' => $oSet->Count())); + self::$m_aCacheObjectByColumn[$sClass][$sAttCode][$value] = null; + } + } + + return self::$m_aCacheObjectByColumn[$sClass][$sAttCode][$value]; + } + public static function GetObjectFromOQL($sQuery, $aParams = null, $bAllowAllData = false) { $oFilter = DBObjectSearch::FromOQL($sQuery, $aParams); diff --git a/synchro/synchro_import.php b/synchro/synchro_import.php index c259ca20c..2f32ab225 100644 --- a/synchro/synchro_import.php +++ b/synchro/synchro_import.php @@ -409,7 +409,14 @@ try $aValues = array(); // Used to build the insert query foreach ($aRow as $iCol => $value) { - $aValues[] = CMDBSource::Quote($value); + if (is_null($value)) + { + $aValues[] = 'NULL'; + } + else + { + $aValues[] = CMDBSource::Quote($value); + } } $sValues = implode(', ', $aValues); $sInsert = "INSERT INTO `$sTable` ($sInsertColumns) VALUES ($sValues)"; diff --git a/synchro/synchrodatasource.class.inc.php b/synchro/synchrodatasource.class.inc.php index c5d888449..180dbf18b 100644 --- a/synchro/synchrodatasource.class.inc.php +++ b/synchro/synchrodatasource.class.inc.php @@ -53,6 +53,8 @@ class SynchroDataSource extends cmdbAbstractObject MetaModel::Init_AddAttribute(new AttributeEnum("status", array("allowed_values"=>new ValueSetEnum('implementation,production,obsolete'), "sql"=>"status", "default_value"=>"implementation", "is_null_allowed"=>false, "depends_on"=>array()))); MetaModel::Init_AddAttribute(new AttributeExternalKey("user_id", array("targetclass"=>"User", "jointype"=>null, "allowed_values"=>null, "sql"=>"user_id", "is_null_allowed"=>true, "on_target_delete"=>DEL_MANUAL, "depends_on"=>array()))); MetaModel::Init_AddAttribute(new AttributeClass("scope_class", array("class_category"=>"bizmodel", "more_values"=>"", "sql"=>"scope_class", "default_value"=>null, "is_null_allowed"=>false, "depends_on"=>array()))); + + // Declared here for a future usage, but ignored so far MetaModel::Init_AddAttribute(new AttributeString("scope_restriction", array("allowed_values"=>null, "sql"=>"scope_restriction", "default_value"=>null, "is_null_allowed"=>true, "depends_on"=>array()))); //MetaModel::Init_AddAttribute(new AttributeDateTime("last_synchro_date", array("allowed_values"=>null, "sql"=>"last_synchro_date", "default_value"=>"", "is_null_allowed"=>false, "depends_on"=>array()))); @@ -76,7 +78,7 @@ class SynchroDataSource extends cmdbAbstractObject MetaModel::Init_AddAttribute(new AttributeLinkedSet("status_list", array("linked_class"=>"SynchroLog", "ext_key_to_me"=>"sync_source_id", "allowed_values"=>null, "count_min"=>0, "count_max"=>0, "depends_on"=>array()))); // Display lists - MetaModel::Init_SetZListItems('details', array('name', 'description', 'scope_class', 'scope_restriction', 'status', 'user_id', 'full_load_periodicity', 'reconciliation_policy', 'action_on_zero', 'action_on_one', 'action_on_multiple', 'delete_policy', 'delete_policy_update', 'delete_policy_retention' /*'attribute_list'*/, 'status_list')); // Attributes to be displayed for the complete details + MetaModel::Init_SetZListItems('details', array('name', 'description', 'scope_class', /*'scope_restriction', */'status', 'user_id', 'full_load_periodicity', 'reconciliation_policy', 'action_on_zero', 'action_on_one', 'action_on_multiple', 'delete_policy', 'delete_policy_update', 'delete_policy_retention' /*'attribute_list'*/, 'status_list')); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('scope_class', 'status', 'user_id', 'full_load_periodicity')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('name', 'status', 'scope_class', 'user_id')); // Criteria of the std search form @@ -112,7 +114,15 @@ class SynchroDataSource extends cmdbAbstractObject } else { - $oAttribute = new SynchroAttribute(); + if ($oAttDef->IsExternalKey()) + { + $oAttribute = new SynchroAttExtKey(); + $oAttribute->Set('reconciliation_attcode', ''); // Blank means by pkey + } + else + { + $oAttribute = new SynchroAttribute(); + } $oAttribute->Set('sync_source_id', $this->GetKey()); $oAttribute->Set('attcode', $sAttCode); $oAttribute->Set('reconcile', MetaModel::IsReconcKey($this->GetTargetClass(), $sAttCode) ? 1 : 0); @@ -327,7 +337,16 @@ EOF { if(!isset($aAttributes[$sAttCode])) { - $oAttribute = new SynchroAttribute(); + $oAttDef = MetaModel::GetAttributeDef($this->GetTargetClass(), $sAttCode); + if ($oAttDef->IsExternalKey()) + { + $oAttribute = new SynchroAttExtKey(); + $oAttribute->Set('reconciliation_attcode', ''); // Blank means by pkey + } + else + { + $oAttribute = new SynchroAttribute(); + } $oAttribute->Set('sync_source_id', $this->GetKey()); $oAttribute->Set('attcode', $sAttCode); } @@ -419,7 +438,16 @@ EOF { if ($oAttDef->IsScalar() && $oAttDef->IsWritable()) { - $oAttribute = new SynchroAttribute(); + $oAttDef = MetaModel::GetAttributeDef($this->GetTargetClass(), $sAttCode); + if ($oAttDef->IsExternalKey()) + { + $oAttribute = new SynchroAttExtKey(); + $oAttribute->Set('reconciliation_attcode', ''); // Blank means by pkey + } + else + { + $oAttribute = new SynchroAttribute(); + } $oAttribute->Set('sync_source_id', $this->GetKey()); $oAttribute->Set('attcode', $sAttCode); $oAttribute->Set('reconcile', MetaModel::IsReconcKey($this->GetTargetClass(), $sAttCode) ? 1 : 0); @@ -668,15 +696,15 @@ EOF { if ($oSyncAtt->Get('update')) { - $aAttCodesToUpdate[] = $oSyncAtt->Get('attcode'); + $aAttCodesToUpdate[$oSyncAtt->Get('attcode')] = $oSyncAtt; } if ($oSyncAtt->Get('reconcile')) { - $aAttCodesToReconcile[] = $oSyncAtt->Get('attcode'); + $aAttCodesToReconcile[$oSyncAtt->Get('attcode')] = $oSyncAtt; } - $aAttCodesExpected[] = $oSyncAtt->Get('attcode'); + $aAttCodesExpected[$oSyncAtt->Get('attcode')] = $oSyncAtt; } - $aColumns = $this->GetSQLColumns($aAttCodesExpected); + $aColumns = $this->GetSQLColumns(array_keys($aAttCodesExpected)); $aExtDataFields = array_keys($aColumns); $aExtDataFields[] = 'primary_key'; $aExtDataSpec = array( @@ -693,17 +721,19 @@ EOF elseif ($this->Get('reconciliation_policy') == 'use_primary_key') { // Override the setings made at the attribute level ! - $aReconciliationKeys = array("primary_key"); + $aReconciliationKeys = array("primary_key" => null); } - $aTraces[] = "Reconciliation on: {".implode(', ', $aReconciliationKeys)."}"; + + $aTraces[] = "Update of: {".implode(', ', array_keys($aAttCodesToUpdate))."}"; + $aTraces[] = "Reconciliation on: {".implode(', ', array_keys($aReconciliationKeys))."}"; $aAttributes = array(); - foreach($aAttCodesToUpdate as $sAttCode) + foreach($aAttCodesToUpdate as $sAttCode => $oSyncAtt) { $oAttDef = MetaModel::GetAttributeDef($this->GetTargetClass(), $sAttCode); if ($oAttDef->IsWritable() && $oAttDef->IsScalar()) { - $aAttributes[] = $sAttCode; + $aAttributes[$sAttCode] = $oSyncAtt; } } @@ -761,9 +791,17 @@ EOF { $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); - foreach($oAttDef->GetSQLColumns() as $sField => $sDBFieldType) + if ($oAttDef->IsExternalKey()) { - $aColumns[$sField] = $sDBFieldType; + // The pkey might be used as well as any other key column + $aColumns[$sAttCode] = 'VARCHAR (255)'; + } + else + { + foreach($oAttDef->GetSQLColumns() as $sField => $sDBFieldType) + { + $aColumns[$sField] = $sDBFieldType; + } } } return $aColumns; @@ -1068,7 +1106,7 @@ class SynchroReplica extends DBObject // If needed, construct the query used for the reconciliation if (!isset(self::$aSearches[$oDataSource->GetKey()])) { - foreach($aReconciliationKeys as $sFilterCode) + foreach($aReconciliationKeys as $sFilterCode => $oSyncAtt) { $aCriterias[] = ($sFilterCode == 'primary_key' ? 'id' : $sFilterCode).' = :'.$sFilterCode; } @@ -1077,9 +1115,9 @@ class SynchroReplica extends DBObject } // Get the criterias for the search $aFilterValues = array(); - foreach($aReconciliationKeys as $sFilterCode) + foreach($aReconciliationKeys as $sFilterCode => $oSyncAtt) { - $value = $this->GetValueFromExtData($sFilterCode); + $value = $this->GetValueFromExtData($sFilterCode, $oSyncAtt, $oStatLog, $aTraces); if (!is_null($value)) { $aFilterValues[$sFilterCode] = $value; @@ -1184,9 +1222,9 @@ class SynchroReplica extends DBObject protected function UpdateObjectFromReplica($oDestObj, $aAttributes, $oChange, &$oStatLog, &$aTraces, $sStatsCode, $sStatsCodeError) { $aValueTrace = array(); - foreach($aAttributes as $sAttCode) + foreach($aAttributes as $sAttCode => $oSyncAtt) { - $value = $this->GetValueFromExtData($sAttCode); + $value = $this->GetValueFromExtData($sAttCode, $oSyncAtt, $oStatLog, $aTraces); if (!is_null($value)) { $oDestObj->Set($sAttCode, $value); @@ -1228,9 +1266,9 @@ class SynchroReplica extends DBObject try { $aValueTrace = array(); - foreach($aAttributes as $sAttCode) + foreach($aAttributes as $sAttCode => $oSyncAtt) { - $value = $this->GetValueFromExtData($sAttCode); + $value = $this->GetValueFromExtData($sAttCode, $oSyncAtt, $oStatLog, $aTraces); if (!is_null($value)) { $oDestObj->Set($sAttCode, $value); @@ -1319,10 +1357,41 @@ class SynchroReplica extends DBObject /** * Get the value from the 'Extended Data' located in the synchro_data_xxx table for this replica */ - protected function GetValueFromExtData($sColumnName) + protected function GetValueFromExtData($sColumnName, $oSyncAtt, &$oStatLog, &$aTraces) { - // $aData should contain attributes defined either for reconciliation or update - $aData = $this->GetExtendedData(); + // $aData should contain attributes defined either for reconciliation or create/update + $aData = $this->GetExtendedData(); + + // In any case, a null column means "ignore this column" + // + if (is_null($aData[$sColumnName])) + { + return null; + } + + if (!is_null($oSyncAtt) && ($oSyncAtt instanceof SynchroAttExtKey)) + { + $sReconcAttCode = $oSyncAtt->Get('reconciliation_attcode'); + if (!empty($sReconcAttCode)) + { + $oDataSource = MetaModel::GetObject('SynchroDataSource', $this->Get('sync_source_id')); + $sClass = $oDataSource->GetTargetClass(); + $oAttDef = MetaModel::GetAttributeDef($sClass, $sColumnName); + $sRemoteClass = $oAttDef->GetTargetClass(); + $oObj = MetaModel::GetObjectByColumn($sRemoteClass, $sReconcAttCode, $aData[$sColumnName], false); + if ($oObj) + { + return $oObj->GetKey(); + } + else + { + // Note: differs from null (in which case the value would be left unchanged) + $aTraces[] = "Could not find [unique] object for '$sColumnName': searched on $sReconcAttCode = '$aData[$sColumnName]'"; + return 0; + } + } + } + return $aData[$sColumnName]; } } diff --git a/test/test.class.inc.php b/test/test.class.inc.php index 518aab128..ec31729f2 100644 --- a/test/test.class.inc.php +++ b/test/test.class.inc.php @@ -413,13 +413,26 @@ abstract class TestBizModel extends TestHandler } protected $m_oChange; + protected function GetCurrentChange() + { + if (!isset($this->m_oChange)) + { + new CMDBChange(); + $oMyChange = MetaModel::NewObject("CMDBChange"); + $oMyChange->Set("date", time()); + $oMyChange->Set("userinfo", "Someone doing some tests"); + $iChangeId = $oMyChange->DBInsertNoReload(); + $this->m_oChange = $oMyChange; + } + return $this->m_oChange; + } protected function ObjectToDB($oNew, $bReload = false) { - list($bRes, $aIssues) = $oNew->CheckToWrite(); - if (!$bRes) - { - throw new CoreException('Could not create object, unexpected values', array('issues' => $aIssues)); - } +// list($bRes, $aIssues) = $oNew->CheckToWrite(); +// if (!$bRes) +// { +// throw new CoreException('Could not create object, unexpected values', array('issues' => $aIssues)); +// } if ($oNew instanceof CMDBObject) { if (!isset($this->m_oChange)) @@ -431,13 +444,14 @@ abstract class TestBizModel extends TestHandler $iChangeId = $oMyChange->DBInsertNoReload(); $this->m_oChange = $oMyChange; } + $oChange = $this->GetCurrentChange(); if ($bReload) { - $iId = $oNew->DBInsertTracked($this->m_oChange); + $iId = $oNew->DBInsertTracked($oChange); } else { - $iId = $oNew->DBInsertTrackedNoReload($this->m_oChange); + $iId = $oNew->DBInsertTrackedNoReload($oChange); } } else @@ -454,6 +468,18 @@ abstract class TestBizModel extends TestHandler return $iId; } + protected function UpdateObjectInDB($oObject) + { + if ($oObject instanceof CMDBObject) + { + $oChange = $this->GetCurrentChange(); + $oObject->DBUpdateTracked($oChange); + } + else + { + $oObject->DBUpdate(); + } + } protected function ResetDB() { if (MetaModel::DBExists(false)) diff --git a/test/testlist.inc.php b/test/testlist.inc.php index 09122f6b8..820c895ba 100644 --- a/test/testlist.inc.php +++ b/test/testlist.inc.php @@ -1836,23 +1836,27 @@ class TestDataExchange extends TestBizModel $oDataSource->Set('delete_policy', $aSingleScenario['delete_policy']); $oDataSource->Set('delete_policy_update', $aSingleScenario['delete_policy_update']); $oDataSource->Set('delete_policy_retention', $aSingleScenario['delete_policy_retention']); - $iDataSourceId = $this->ObjectToDB($oDataSource); + $iDataSourceId = $this->ObjectToDB($oDataSource, true /* reload */); $oAttributeSet = $oDataSource->Get('attribute_list'); while ($oAttribute = $oAttributeSet->Fetch()) { - if (array_key_exists($aSingleScenario['attributes'], $oAttribute->Get('attcode'))) + if (array_key_exists($oAttribute->Get('attcode'), $aSingleScenario['attributes'])) { $aAttribInfo = $aSingleScenario['attributes'][$oAttribute->Get('attcode')]; - $oSyncAtt->Set('update', $aAttribInfo['do_update']); - $oSyncAtt->Set('reconcile', $aAttribInfo['do_reconcile']); + if (array_key_exists('reconciliation_attcode', $aAttribInfo)) + { + $oAttribute->Set('reconciliation_attcode', $aAttribInfo['reconciliation_attcode']); + } + $oAttribute->Set('update', $aAttribInfo['do_update']); + $oAttribute->Set('reconcile', $aAttribInfo['do_reconcile']); } else { - $oSyncAtt->Set('update', false); - $oSyncAtt->Set('reconcile', false); + $oAttribute->Set('update', false); + $oAttribute->Set('reconcile', false); } - $oAttribute->DBUpdateTracked(); + $this->UpdateObjectInDB($oAttribute); } // Prepare list of prefixes -> make sure objects are unique with regard to the reconciliation scheme @@ -1863,7 +1867,7 @@ class TestDataExchange extends TestBizModel } foreach($aSingleScenario['attributes'] as $sAttCode => $aAttribInfo) { - if ($aAttribInfo['do_reconcile']) + if (isset($aAttribInfo['automatic_prefix']) && $aAttribInfo['automatic_prefix']) { $aPrefixes[$sAttCode] = 'TEST_'.$iDataSourceId.'_'; } @@ -1892,7 +1896,14 @@ class TestDataExchange extends TestBizModel { // Check the status (while ignoring existing objects) // - $oObjects = new DBObjectSet(DBObjectSearch::FromOQL("SELECT $sClass WHERE id NOT IN($sExistingIds)")); + if (empty($sExistingIds)) + { + $oObjects = new DBObjectSet(DBObjectSearch::FromOQL("SELECT $sClass")); + } + else + { + $oObjects = new DBObjectSet(DBObjectSearch::FromOQL("SELECT $sClass WHERE id NOT IN($sExistingIds)")); + } $aFound = $oObjects->ToArray(); $aErrors_Unexpected = array(); foreach($aFound as $iObj => $oObj) @@ -1973,8 +1984,15 @@ class TestDataExchange extends TestBizModel $aFinalData = array(); foreach($aDataRow as $iCol => $value) { - $sAttCode = $aSourceAttributes[$iCol]; - $aFinalData[] = $aPrefixes[$sAttCode].$value; + if (is_null($value)) + { + $aFinalData[] = ''; + } + else + { + $sAttCode = $aSourceAttributes[$iCol]; + $aFinalData[] = $aPrefixes[$sAttCode].$value; + } } $sCsvData .= implode(';', $aFinalData)."\n"; } @@ -2010,6 +2028,7 @@ class TestDataExchange extends TestBizModel { $sCsvDataViewable = $sCsvData; } + $sCsvDataViewable = htmlentities($sCsvDataViewable); echo "
\n"; echo "
$sCsvDataViewable
\n"; @@ -2020,10 +2039,6 @@ class TestDataExchange extends TestBizModel { throw new UnitTestException('Encountered an Exception during the last import/synchro'); } - if (stripos($sRes, 'error') !== false) - { - throw new UnitTestException('Encountered an Error during the last import/synchro'); - } } } return; @@ -2035,23 +2050,28 @@ class TestDataExchange extends TestBizModel { $aScenarios = array( array( - 'desc' => 'Simple scenario with delete option', + 'desc' => 'Simple scenario with delete option (and extkey given as org/name)', 'login' => 'admin', 'password' => 'admin', 'target_class' => 'ApplicationSolution', - 'full_load_periodicity' => '1 hour', + 'full_load_periodicity' => 3600, // should be ignored in this case 'reconciliation_policy' => 'use_attributes', 'action_on_zero' => 'create', 'action_on_one' => 'update', 'action_on_multiple' => 'error', - 'delete_policy' => 'update_then_delete', - 'delete_policy_update' => 'status:obsolete', - 'delete_policy_retention' => '', + 'delete_policy' => 'delete', + 'delete_policy_update' => '', + 'delete_policy_retention' => 0, 'source_data' => array( array('primary_key', 'org_id', 'name', 'status'), array( - array('obj_A', 2, 'obj_A', 'production'), - array('obj_B', 2, 'obj_B', 'production'), + array('obj_A', null, 'obj_A', 'production'), // org_id unchanged + array('obj_B', '_DUMMY_', 'obj_B', 'production'), // error, '_DUMMY_' unknown + array('obj_C', 'SOMECODE', 'obj_C', 'production'), + array('obj_D', null, 'obj_D', 'production'), + array('obj_E', '_DUMMY_', 'obj_E', 'production'), + ), + array( ), array( ), @@ -2061,13 +2081,22 @@ class TestDataExchange extends TestBizModel array( // Initial state array(2, 'obj_A', 'production'), - ), - array( - array(2, 'obj_A', 'production'), array(2, 'obj_B', 'production'), ), array( - array(2, 'obj_A', 'obsolete'), + array(2, 'obj_A', 'production'), + array(2, 'obj_B', 'production'), + array(1, 'obj_C', 'production'), + ), + array( + array(2, 'obj_A', 'production'), + array(2, 'obj_B', 'production'), + // deleted ! + ), + // The only diff here is into the log + array( + array(2, 'obj_A', 'production'), + array(2, 'obj_B', 'production'), // deleted ! ), ), @@ -2075,10 +2104,12 @@ class TestDataExchange extends TestBizModel 'org_id' => array( 'do_reconcile' => false, 'do_update' => true, + 'reconciliation_attcode' => 'code', ), 'name' => array( 'do_reconcile' => true, 'do_update' => true, + 'automatic_prefix' => true, // unique id ), 'status' => array( 'do_reconcile' => false, @@ -2087,24 +2118,24 @@ class TestDataExchange extends TestBizModel ), ), //); - //$aScenarios = array( + //$aXXXXScenarios = array( array( - 'desc' => 'Update then delete with retention (to complete with manual testing)', + 'desc' => 'Update then delete with retention (to complete with manual testing) and reconciliation on org/name', 'login' => 'admin', 'password' => 'admin', 'target_class' => 'ApplicationSolution', - 'full_load_periodicity' => '1 hour', + 'full_load_periodicity' => 3600, 'reconciliation_policy' => 'use_attributes', 'action_on_zero' => 'create', 'action_on_one' => 'update', 'action_on_multiple' => 'error', 'delete_policy' => 'update_then_delete', 'delete_policy_update' => 'status:obsolete', - 'delete_policy_retention' => '1 hour', + 'delete_policy_retention' => 5, 'source_data' => array( array('primary_key', 'org_id', 'name', 'status'), array( - array('obj_A', 2, 'obj_A', 'production'), + array('obj_A', 'OMED', 'obj_A', 'production'), ), array( ), @@ -2124,12 +2155,14 @@ class TestDataExchange extends TestBizModel ), 'attributes' => array( 'org_id' => array( - 'do_reconcile' => false, + 'do_reconcile' => true, 'do_update' => true, + 'reconciliation_attcode' => 'code', ), 'name' => array( 'do_reconcile' => true, 'do_update' => true, + 'automatic_prefix' => true, // unique id ), 'status' => array( 'do_reconcile' => false, @@ -2138,20 +2171,20 @@ class TestDataExchange extends TestBizModel ), ), //); - //$aScenarios = array( + //$aXXScenarios = array( array( 'desc' => 'Simple scenario loading a few ApplicationSolution', 'login' => 'admin', 'password' => 'admin', 'target_class' => 'ApplicationSolution', - 'full_load_periodicity' => '1 hour', + 'full_load_periodicity' => 3600, 'reconciliation_policy' => 'use_attributes', 'action_on_zero' => 'create', 'action_on_one' => 'update', 'action_on_multiple' => 'error', 'delete_policy' => 'update', 'delete_policy_update' => 'status:obsolete', - 'delete_policy_retention' => '', + 'delete_policy_retention' => 0, 'source_data' => array( array('primary_key', 'org_id', 'name', 'status'), array( @@ -2159,6 +2192,11 @@ class TestDataExchange extends TestBizModel array('obj_B', 2, 'obj_B', 'implementation'), array('obj_C', 2, 'obj_C', 'implementation'), ), + array( + array('obj_A', 2, 'obj_A', 'production'), + array('obj_B', 2, 'obj_B', 'implementation'), + array('obj_C', 2, 'obj_C', 'implementation'), + ), array( array('obj_A', 2, 'obj_A', 'production'), array('obj_C', 2, 'obj_C', 'implementation'), @@ -2167,6 +2205,9 @@ class TestDataExchange extends TestBizModel array( array('obj_C', 2, 'obj_C', 'production'), ), + array( + array('obj_C', 2, 'obj_C', 'production'), + ), ), 'target_data' => array( array('org_id', 'name', 'status'), @@ -2182,6 +2223,12 @@ class TestDataExchange extends TestBizModel array(2, 'obj_B', 'implementation'), array(2, 'obj_C', 'implementation'), ), + array( + array(2, 'obj_A', 'production'), + array(2, 'obj_B', 'production'), + array(2, 'obj_B', 'implementation'), + array(2, 'obj_C', 'implementation'), + ), array( array(2, 'obj_A', 'production'), array(2, 'obj_B', 'production'), @@ -2196,6 +2243,13 @@ class TestDataExchange extends TestBizModel array(2, 'obj_C', 'production'), array(2, 'obj_D', 'obsolete'), ), + array( + array(2, 'obj_A', 'obsolete'), + array(2, 'obj_B', 'production'), + array(2, 'obj_B', 'implementation'), + array(2, 'obj_C', 'production'), + array(2, 'obj_D', 'obsolete'), + ), ), 'attributes' => array( 'org_id' => array( @@ -2205,6 +2259,7 @@ class TestDataExchange extends TestBizModel 'name' => array( 'do_reconcile' => true, 'do_update' => true, + 'automatic_prefix' => true, // unique id ), 'status' => array( 'do_reconcile' => false,