diff --git a/datamodels/2.x/combodo-db-tools/cs.dict.combodo-db-tools.php b/datamodels/2.x/combodo-db-tools/cs.dict.combodo-db-tools.php
new file mode 100644
index 000000000..f6719c0b6
--- /dev/null
+++ b/datamodels/2.x/combodo-db-tools/cs.dict.combodo-db-tools.php
@@ -0,0 +1,87 @@
+
+ */
+// Database inconsistencies
+Dict::Add('CS CZ', 'Czech', 'Čeština', array(
+ // Dictionary entries go here
+ 'Menu:DBToolsMenu' => 'DB Tools~~',
+ 'DBTools:Class' => 'Class~~',
+ 'DBTools:Title' => 'Database Maintenance Tools~~',
+ 'DBTools:ErrorsFound' => 'Errors Found~~',
+ 'DBTools:Error' => 'Error~~',
+ 'DBTools:Count' => 'Count~~',
+ 'DBTools:SQLquery' => 'SQL query~~',
+ 'DBTools:FixitSQLquery' => 'SQL query To Fix it (indication)~~',
+ 'DBTools:SQLresult' => 'SQL result~~',
+ 'DBTools:NoError' => 'The database is OK~~',
+ 'DBTools:HideIds' => 'Error List~~',
+ 'DBTools:ShowIds' => 'Detailed view~~',
+ 'DBTools:ShowReport' => 'Report~~',
+ 'DBTools:IntegrityCheck' => 'Integrity check~~',
+ 'DBTools:FetchCheck' => 'Fetch Check (long)~~',
+
+ 'DBTools:Analyze' => 'Analyze~~',
+ 'DBTools:Details' => 'Show Details~~',
+ 'DBTools:ShowAll' => 'Show All Errors~~',
+
+ 'DBTools:Inconsistencies' => 'Database inconsistencies~~',
+
+ 'DBAnalyzer-Integrity-OrphanRecord' => 'Orphan record in `%1$s`, it should have its counterpart in table `%2$s`~~',
+ 'DBAnalyzer-Integrity-InvalidExtKey' => 'Invalid external key %1$s (column: `%2$s.%3$s`)~~',
+ 'DBAnalyzer-Integrity-MissingExtKey' => 'Missing external key %1$s (column: `%2$s.%3$s`)~~',
+ 'DBAnalyzer-Integrity-InvalidValue' => 'Invalid value for %1$s (column: `%2$s.%3$s`)~~',
+ 'DBAnalyzer-Integrity-UsersWithoutProfile' => 'Some user accounts have no profile at all~~',
+ 'DBAnalyzer-Fetch-Count-Error' => 'Fetch count error in `%1$s`, %2$d entries fetched / %3$d counted~~',
+));
+
+// Database Info
+Dict::Add('CS CZ', 'Czech', 'Čeština', array(
+ 'DBTools:DatabaseInfo' => 'Database Information~~',
+ 'DBTools:Base' => 'Base~~',
+ 'DBTools:Size' => 'Size~~',
+));
+
+// Lost attachments
+Dict::Add('CS CZ', 'Czech', 'Čeština', array(
+ 'DBTools:LostAttachments' => 'Lost attachments~~',
+ 'DBTools:LostAttachments:Disclaimer' => 'Here you can search your database for lost or misplaced attachments. This is NOT a data recovery tool, is does not retrieve deleted data.~~',
+
+ 'DBTools:LostAttachments:Button:Analyze' => 'Analyze~~',
+ 'DBTools:LostAttachments:Button:Restore' => 'Restore~~',
+ 'DBTools:LostAttachments:Button:Restore:Confirm' => 'This action cannot be undone, please confirm that you want to restore the selected files.~~',
+ 'DBTools:LostAttachments:Button:Busy' => 'Please wait...~~',
+
+ 'DBTools:LostAttachments:Step:Analyze' => 'First, search for lost/misplaced attachments by analyzing the database.~~',
+
+ 'DBTools:LostAttachments:Step:AnalyzeResults' => 'Analyze results:~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:None' => 'Great! Every thing seems to be at the right place.~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:Some' => 'Some attachments (%1$d) seem to be misplaced. Take a look at the following list and check the ones you would like to move.~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:Item:Filename' => 'Filename~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:Item:CurrentLocation' => 'Current location~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:Item:TargetLocation' => 'Move to...~~',
+
+ 'DBTools:LostAttachments:Step:RestoreResults' => 'Restore results:~~',
+ 'DBTools:LostAttachments:Step:RestoreResults:Results' => '%1$d/%2$d attachments were restored.~~',
+
+ 'DBTools:LostAttachments:StoredAsInlineImage' => 'Stored as inline image~~',
+ 'DBTools:LostAttachments:History' => 'Attachment "%1$s" restored with DB tools~~'
+));
diff --git a/datamodels/2.x/combodo-db-tools/da.dict.combodo-db-tools.php b/datamodels/2.x/combodo-db-tools/da.dict.combodo-db-tools.php
new file mode 100644
index 000000000..9adde2565
--- /dev/null
+++ b/datamodels/2.x/combodo-db-tools/da.dict.combodo-db-tools.php
@@ -0,0 +1,87 @@
+
+ */
+// Database inconsistencies
+Dict::Add('DA DA', 'Danish', 'Dansk', array(
+ // Dictionary entries go here
+ 'Menu:DBToolsMenu' => 'DB Tools~~',
+ 'DBTools:Class' => 'Class~~',
+ 'DBTools:Title' => 'Database Maintenance Tools~~',
+ 'DBTools:ErrorsFound' => 'Errors Found~~',
+ 'DBTools:Error' => 'Error~~',
+ 'DBTools:Count' => 'Count~~',
+ 'DBTools:SQLquery' => 'SQL query~~',
+ 'DBTools:FixitSQLquery' => 'SQL query To Fix it (indication)~~',
+ 'DBTools:SQLresult' => 'SQL result~~',
+ 'DBTools:NoError' => 'The database is OK~~',
+ 'DBTools:HideIds' => 'Error List~~',
+ 'DBTools:ShowIds' => 'Detailed view~~',
+ 'DBTools:ShowReport' => 'Report~~',
+ 'DBTools:IntegrityCheck' => 'Integrity check~~',
+ 'DBTools:FetchCheck' => 'Fetch Check (long)~~',
+
+ 'DBTools:Analyze' => 'Analyze~~',
+ 'DBTools:Details' => 'Show Details~~',
+ 'DBTools:ShowAll' => 'Show All Errors~~',
+
+ 'DBTools:Inconsistencies' => 'Database inconsistencies~~',
+
+ 'DBAnalyzer-Integrity-OrphanRecord' => 'Orphan record in `%1$s`, it should have its counterpart in table `%2$s`~~',
+ 'DBAnalyzer-Integrity-InvalidExtKey' => 'Invalid external key %1$s (column: `%2$s.%3$s`)~~',
+ 'DBAnalyzer-Integrity-MissingExtKey' => 'Missing external key %1$s (column: `%2$s.%3$s`)~~',
+ 'DBAnalyzer-Integrity-InvalidValue' => 'Invalid value for %1$s (column: `%2$s.%3$s`)~~',
+ 'DBAnalyzer-Integrity-UsersWithoutProfile' => 'Some user accounts have no profile at all~~',
+ 'DBAnalyzer-Fetch-Count-Error' => 'Fetch count error in `%1$s`, %2$d entries fetched / %3$d counted~~',
+));
+
+// Database Info
+Dict::Add('DA DA', 'Danish', 'Dansk', array(
+ 'DBTools:DatabaseInfo' => 'Database Information~~',
+ 'DBTools:Base' => 'Base~~',
+ 'DBTools:Size' => 'Size~~',
+));
+
+// Lost attachments
+Dict::Add('DA DA', 'Danish', 'Dansk', array(
+ 'DBTools:LostAttachments' => 'Lost attachments~~',
+ 'DBTools:LostAttachments:Disclaimer' => 'Here you can search your database for lost or misplaced attachments. This is NOT a data recovery tool, is does not retrieve deleted data.~~',
+
+ 'DBTools:LostAttachments:Button:Analyze' => 'Analyze~~',
+ 'DBTools:LostAttachments:Button:Restore' => 'Restore~~',
+ 'DBTools:LostAttachments:Button:Restore:Confirm' => 'This action cannot be undone, please confirm that you want to restore the selected files.~~',
+ 'DBTools:LostAttachments:Button:Busy' => 'Please wait...~~',
+
+ 'DBTools:LostAttachments:Step:Analyze' => 'First, search for lost/misplaced attachments by analyzing the database.~~',
+
+ 'DBTools:LostAttachments:Step:AnalyzeResults' => 'Analyze results:~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:None' => 'Great! Every thing seems to be at the right place.~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:Some' => 'Some attachments (%1$d) seem to be misplaced. Take a look at the following list and check the ones you would like to move.~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:Item:Filename' => 'Filename~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:Item:CurrentLocation' => 'Current location~~',
+ 'DBTools:LostAttachments:Step:AnalyzeResults:Item:TargetLocation' => 'Move to...~~',
+
+ 'DBTools:LostAttachments:Step:RestoreResults' => 'Restore results:~~',
+ 'DBTools:LostAttachments:Step:RestoreResults:Results' => '%1$d/%2$d attachments were restored.~~',
+
+ 'DBTools:LostAttachments:StoredAsInlineImage' => 'Stored as inline image~~',
+ 'DBTools:LostAttachments:History' => 'Attachment "%1$s" restored with DB tools~~'
+));
diff --git a/datamodels/2.x/combodo-db-tools/datamodel.combodo-db-tools.xml b/datamodels/2.x/combodo-db-tools/datamodel.combodo-db-tools.xml
new file mode 100644
index 000000000..5d7cdadbd
--- /dev/null
+++ b/datamodels/2.x/combodo-db-tools/datamodel.combodo-db-tools.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/datamodels/2.x/combodo-db-tools/db_analyzer.class.inc.php b/datamodels/2.x/combodo-db-tools/db_analyzer.class.inc.php
new file mode 100644
index 000000000..6a3162a92
--- /dev/null
+++ b/datamodels/2.x/combodo-db-tools/db_analyzer.class.inc.php
@@ -0,0 +1,476 @@
+
+//
+
+class DatabaseAnalyzer
+{
+ var $iTimeLimitPerOperation;
+
+ public function __construct($iTimeLimitPerOperation = null)
+ {
+ $this->iTimeLimitPerOperation = $iTimeLimitPerOperation;
+ }
+
+ /**
+ * @param $sSelWrongRecs
+ * @param $sFixItRequest
+ * @param $sErrorDesc
+ * @param $sClass
+ * @param $aErrorsAndFixes
+ * @param array $aValueNames
+ *
+ * @throws \MySQLException
+ */
+ private function ExecQuery($sSelWrongRecs, $sFixItRequest, $sErrorDesc, $sClass, &$aErrorsAndFixes, $aValueNames = array())
+ {
+ if (!is_null($this->iTimeLimitPerOperation))
+ {
+ set_time_limit($this->iTimeLimitPerOperation);
+ }
+
+ $aWrongRecords = CMDBSource::QueryToArray($sSelWrongRecs);
+ if (count($aWrongRecords) > 0)
+ {
+ foreach($aWrongRecords as $aRes)
+ {
+ if (!isset($aErrorsAndFixes[$sClass][$sErrorDesc]))
+ {
+ $aErrorsAndFixes[$sClass][$sErrorDesc] = array(
+ 'count' => 1,
+ 'query' => $sSelWrongRecs,
+ );
+ if (!empty($sFixItRequest))
+ {
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['fixit'] = array($sFixItRequest);
+ }
+ }
+ else
+ {
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['count'] += 1;
+ }
+ if (empty($aValueNames))
+ {
+ $aValues = array('id' => $aRes['id']);
+ }
+ else
+ {
+ $aValues = array();
+ foreach ($aValueNames as $sValueName)
+ {
+ $aValues[$sValueName] = $aRes[$sValueName];
+ }
+ }
+
+ if (isset($aRes['value']))
+ {
+ $value = $aRes['value'];
+ $aValues['value'] = $value;
+ if (!isset($aErrorsAndFixes[$sClass][$sErrorDesc]['values'][$value]))
+ {
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['values'][$value] = 1;
+ }
+ else
+ {
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['values'][$value] += 1;
+ }
+ }
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['res'][] = $aValues;
+ }
+ }
+ }
+
+ /**
+ * @param $aClassSelection
+ *
+ * @return array
+ * @throws \CoreException
+ * @throws \MySQLException
+ * @throws \Exception
+ */
+ public function CheckIntegrity($aClassSelection)
+ {
+ // Getting and setting time limit are not symetric:
+ // www.php.net/manual/fr/function.set-time-limit.php#72305
+ $iPreviousTimeLimit = ini_get('max_execution_time');
+
+ $aErrorsAndFixes = array();
+
+ if (empty($aClassSelection))
+ {
+ $aClassSelection = MetaModel::GetClasses();
+ }
+ else
+ {
+ $aClasses = $aClassSelection;
+ foreach($aClasses as $sClass)
+ {
+ $aExpectedClasses = MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL);
+ $aClassSelection = array_merge($aClassSelection, $aExpectedClasses);
+ }
+ $aClassSelection = array_unique($aClassSelection);
+ }
+
+ foreach($aClassSelection as $sClass)
+ {
+ // Check uniqueness rules
+ if (method_exists('MetaModel', 'GetUniquenessRules'))
+ {
+ $this->CheckUniquenessRules($sClass, $aErrorsAndFixes);
+ }
+
+ if (!MetaModel::HasTable($sClass))
+ {
+ continue;
+ }
+
+ $sRootClass = MetaModel::GetRootClass($sClass);
+ $sTable = MetaModel::DBGetTable($sClass);
+ $sKeyField = MetaModel::DBGetKey($sClass);
+
+ if (!MetaModel::IsStandaloneClass($sClass))
+ {
+ if (!MetaModel::IsRootClass($sClass))
+ {
+ $sRootTable = MetaModel::DBGetTable($sRootClass);
+ $sRootKey = MetaModel::DBGetKey($sRootClass);
+
+ $this->CheckRecordsInRootTable($sTable, $sKeyField, $sRootTable, $sRootKey, $sClass, $aErrorsAndFixes);
+ $this->CheckRecordsInChildTable($sRootClass, $sClass, $sRootTable, $sRootKey, $sTable, $sKeyField, $aErrorsAndFixes);
+ }
+ }
+
+ foreach(MetaModel::ListAttributeDefs($sClass) as $sAttCode => $oAttDef)
+ {
+ // Skip this attribute if not defined in this table
+ if (!MetaModel::IsAttributeOrigin($sClass, $sAttCode))
+ {
+ continue;
+ }
+
+ if ($oAttDef->IsExternalKey())
+ {
+ $this->CheckExternalKeys($oAttDef, $sTable, $sKeyField, $sAttCode, $sClass, $aErrorsAndFixes);
+ }
+ elseif ($oAttDef->IsDirectField() && !($oAttDef instanceof AttributeTagSet))
+ {
+ $this->CheckEnums($sClass, $sAttCode, $oAttDef, $sTable, $sKeyField, $aErrorsAndFixes);
+ }
+ }
+ }
+ $this->CheckUsers($aErrorsAndFixes);
+
+ if (!is_null($this->iTimeLimitPerOperation))
+ {
+ set_time_limit($iPreviousTimeLimit);
+ }
+ return $aErrorsAndFixes;
+ }
+
+ /**
+ * @param $sClass
+ * @param $sUniquenessRuleId
+ * @param $aUniquenessRuleProperties
+ * @param $aErrorsAndFixes
+ *
+ * @throws \CoreException
+ * @throws \MissingQueryArgument
+ * @throws \MySQLException
+ * @throws \OQLException
+ * @throws \Exception
+ */
+ private function CheckUniquenessRule($sClass, $sUniquenessRuleId, $aUniquenessRuleProperties, &$aErrorsAndFixes)
+ {
+ $sOqlUniquenessQuery = "SELECT $sClass";
+ if (!(empty($sUniquenessFilter = $aUniquenessRuleProperties['filter'])))
+ {
+ $sOqlUniquenessQuery .= ' WHERE '.$sUniquenessFilter;
+ }
+ $oUniquenessQuery = DBObjectSearch::FromOQL($sOqlUniquenessQuery);
+
+ $aValueNames = array();
+ $aGroupByExpr = array();
+ foreach ($aUniquenessRuleProperties['attributes'] as $sAttributeCode)
+ {
+ $oExpr = Expression::FromOQL("$sClass.$sAttributeCode");
+ $aGroupByExpr[$sAttributeCode] = $oExpr;
+ $aValueNames[] = $sAttributeCode;
+ }
+
+ $aSelectExpr = array();
+
+ $sSQLUniquenessQuery = $oUniquenessQuery->MakeGroupByQuery(array(), $aGroupByExpr, false, $aSelectExpr);
+
+ $sSQLUniquenessQuery .= ' having count(*) > 1';
+
+ $sErrorDesc = $this->GetUniquenessRuleMessage($sUniquenessRuleId);
+
+ $this->ExecQuery($sSQLUniquenessQuery, '', $sErrorDesc, $sClass, $aErrorsAndFixes, $aValueNames);
+ if (isset($aErrorsAndFixes[$sClass][$sErrorDesc]['res']))
+ {
+ $aFixit = array("-- In order to get the duplicates, run the following queries:");
+ foreach ($aErrorsAndFixes[$sClass][$sErrorDesc]['res'] as $aValues)
+ {
+ $oFixSearch = new DBObjectSearch($sClass);
+ foreach ($aValues as $sAttCode => $sValue)
+ {
+ $oFixSearch->AddCondition($sAttCode, $sValue, '=');
+ }
+ $aFixit[] = $oFixSearch->MakeSelectQuery().';';
+ $aFixit[] = "";
+ }
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['fixit'] = $aFixit;
+ }
+
+ return;
+ }
+
+ private function GetUniquenessRuleMessage($sUniquenessRuleId)
+ {
+ // we could add also a specific message if user is admin ("dict key is missing")
+ return Dict::Format('Core:UniquenessDefaultError', $sUniquenessRuleId);
+ }
+
+ /**
+ * @param $sClass
+ * @param array $aErrorsAndFixes
+ *
+ * @throws \CoreException
+ */
+ private function CheckUniquenessRules($sClass, &$aErrorsAndFixes)
+ {
+ if (method_exists('MetaModel', 'GetUniquenessRules'))
+ {
+ $aUniquenessRules = MetaModel::GetUniquenessRules($sClass);
+ foreach ($aUniquenessRules as $sUniquenessRuleId => $aUniquenessRuleProperties)
+ {
+ if ($aUniquenessRuleProperties['disabled'] === true)
+ {
+ continue;
+ }
+ $this->CheckUniquenessRule($sClass, $sUniquenessRuleId, $aUniquenessRuleProperties, $aErrorsAndFixes);
+ }
+ }
+ }
+
+ /**
+ * Check that any record found here has its counterpart in the root table
+ *
+ * @param $sTable
+ * @param $sKeyField
+ * @param $sRootTable
+ * @param $sRootKey
+ * @param $sClass
+ * @param array $aErrorsAndFixes
+ *
+ * @throws \MySQLException
+ */
+ private function CheckRecordsInRootTable($sTable, $sKeyField, $sRootTable, $sRootKey, $sClass, &$aErrorsAndFixes)
+ {
+ $sSelect = "SELECT DISTINCT `$sTable`.`$sKeyField` AS id";
+ $sDelete = "DELETE `$sTable`";
+ $sFilter = "FROM `$sTable` LEFT JOIN `$sRootTable` ON `$sTable`.`$sKeyField` = `$sRootTable`.`$sRootKey` WHERE `$sRootTable`.`$sRootKey` IS NULL";
+ $sSelectWrongRecs = "$sSelect $sFilter";
+ $sFixItRequest = "$sDelete $sFilter";
+ $this->ExecQuery($sSelectWrongRecs, $sFixItRequest, Dict::Format('DBAnalyzer-Integrity-OrphanRecord', $sTable, $sRootTable), $sClass, $aErrorsAndFixes);
+ }
+
+ /**
+ * Check that any record found in the root table and referring to a child class
+ * has its counterpart here (detect orphan nodes -root or in the middle of the hierarchy)
+ *
+ * @param $sRootClass
+ * @param $sClass
+ * @param $sRootTable
+ * @param $sRootKey
+ * @param $sTable
+ * @param $sKeyField
+ * @param array $aErrorsAndFixes
+ *
+ * @throws \CoreException
+ * @throws \MySQLException
+ */
+ private function CheckRecordsInChildTable($sRootClass, $sClass, $sRootTable, $sRootKey, $sTable, $sKeyField, &$aErrorsAndFixes)
+ {
+ $sFinalClassField = MetaModel::DBGetClassField($sRootClass);
+ $aExpectedClasses = MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL);
+ $sExpectedClasses = implode(",", CMDBSource::Quote($aExpectedClasses, true));
+ $sSelect = "SELECT DISTINCT `$sRootTable`.`$sRootKey` AS id";
+ $sDelete = "DELETE `$sRootTable`";
+ $sFilter = "FROM `$sRootTable` LEFT JOIN `$sTable` ON `$sRootTable`.`$sRootKey` = `$sTable`.`$sKeyField` WHERE `$sTable`.`$sKeyField` IS NULL AND `$sRootTable`.`$sFinalClassField` IN ($sExpectedClasses)";
+ $sSelWrongRecs = "$sSelect $sFilter";
+ $sFixItRequest = "$sDelete $sFilter";
+ $this->ExecQuery($sSelWrongRecs, $sFixItRequest, Dict::Format('DBAnalyzer-Integrity-OrphanRecord', $sRootTable, $sTable), $sRootClass, $aErrorsAndFixes);
+ }
+
+ /**
+ * Check that any external field is pointing to an existing object
+ *
+ * @param \AttributeDefinition $oAttDef
+ * @param $sTable
+ * @param $sKeyField
+ * @param $sAttCode
+ * @param $sClass
+ * @param array $aErrorsAndFixes
+ *
+ * @throws \CoreException
+ * @throws \MySQLException
+ */
+ private function CheckExternalKeys(AttributeDefinition $oAttDef, $sTable, $sKeyField, $sAttCode, $sClass, &$aErrorsAndFixes)
+ {
+ $sRemoteClass = $oAttDef->GetTargetClass();
+ $sRemoteTable = MetaModel::DBGetTable($sRemoteClass);
+ $sRemoteKey = MetaModel::DBGetKey($sRemoteClass);
+
+ $aCols = $oAttDef->GetSQLExpressions(); // Workaround a PHP bug: sometimes issuing a Notice if invoking current(somefunc())
+ $sExtKeyField = current($aCols); // get the first column for an external key
+
+ // Note: a class/table may have an external key on itself
+ $sSelect = "SELECT DISTINCT `$sTable`.`$sKeyField` AS id, `$sTable`.`$sExtKeyField` AS value";
+ $sFilter = "FROM `$sTable` LEFT JOIN `$sRemoteTable` AS `{$sRemoteTable}_1` ON `$sTable`.`$sExtKeyField` = `{$sRemoteTable}_1`.`$sRemoteKey`";
+
+ $sFilter = $sFilter." WHERE `{$sRemoteTable}_1`.`$sRemoteKey` IS NULL";
+ // Exclude the records pointing to 0/null from the errors (separate test below)
+ $sFilter .= " AND `$sTable`.`$sExtKeyField` IS NOT NULL";
+ $sFilter .= " AND `$sTable`.`$sExtKeyField` != 0";
+
+ $sSelWrongRecs = "$sSelect $sFilter";
+
+ $sErrorDesc = Dict::Format('DBAnalyzer-Integrity-InvalidExtKey', $sAttCode, $sTable, $sExtKeyField);
+ $this->ExecQuery($sSelWrongRecs, '', $sErrorDesc, $sClass, $aErrorsAndFixes);
+ // Fix it request needs the values of the enum to generate the requests
+ if (isset($aErrorsAndFixes[$sClass][$sErrorDesc]['values']))
+ {
+ $aFixIt = array();
+ $aFixIt[] = "-- Remove inconsistant entries:";
+ $sIds = implode(', ', array_keys($aErrorsAndFixes[$sClass][$sErrorDesc]['values']));
+ $aFixIt[] = "DELETE `$sTable` FROM `$sTable` WHERE `$sTable`.`$sExtKeyField` IN ($sIds)";
+ $aFixIt[] = "";
+ $aFixIt[] = "-- Or fix inconsistant values: Replace XXX with the appropriate value";
+ foreach (array_keys($aErrorsAndFixes[$sClass][$sErrorDesc]['values']) as $sKey)
+ {
+ $aFixIt[] = "UPDATE `$sTable` SET `$sTable`.`$sExtKeyField` = XXX WHERE `$sTable`.`$sExtKeyField` = '$sKey'";
+ }
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['fixit'] = $aFixIt;
+ }
+
+ if (!$oAttDef->IsNullAllowed())
+ {
+ $sSelect = "SELECT DISTINCT `$sTable`.`$sKeyField` AS id";
+ $sDelete = "DELETE `$sTable`";
+ $sFilter = "FROM `$sTable` WHERE `$sTable`.`$sExtKeyField` IS NULL OR `$sTable`.`$sExtKeyField` = 0";
+ $sSelWrongRecs = "$sSelect $sFilter";
+ $sFixItRequest = "$sDelete $sFilter";
+ $sErrorDesc = Dict::Format('DBAnalyzer-Integrity-MissingExtKey', $sAttCode, $sTable, $sExtKeyField);
+ $this->ExecQuery($sSelWrongRecs, $sFixItRequest, $sErrorDesc, $sClass, $aErrorsAndFixes);
+ if (isset($aErrorsAndFixes[$sClass][$sErrorDesc]['count']) && ($aErrorsAndFixes[$sClass][$sErrorDesc]['count'] > 0))
+ {
+ $aFixIt = $aErrorsAndFixes[$sClass][$sErrorDesc]['fixit'];
+ $aFixIt[] = "-- Alternate fix";
+ $aFixIt[] = "-- Replace XXX with the appropriate value";
+ $aFixIt[] = "UPDATE `$sTable` SET `$sTable`.`$sExtKeyField` = XXX WHERE `$sTable`.`$sExtKeyField` IS NULL OR `$sTable`.`$sExtKeyField` = 0";
+ $aAdditionalFixIt = $this->GetSpecificExternalKeysFixItForNull($sTable, $sExtKeyField);
+ foreach ($aAdditionalFixIt as $sFixIt)
+ {
+ $aFixIt[] = $sFixIt;
+ }
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['fixit'] = $aFixIt;
+ }
+ }
+ }
+
+ private function GetSpecificExternalKeysFixItForNull($sTable, $sExtKeyField)
+ {
+ $aFixIt = array();
+ if ($sTable == 'ticket' && $sExtKeyField == 'org_id')
+ {
+ $aFixIt[] = "-- Alternate fix: set the ticket org to the caller org";
+ $aFixIt[] = "UPDATE ticket AS t JOIN contact AS c ON t.caller_id=c.id SET t.org_id=c.org_id WHERE t.org_id IS NULL OR t.org_id = 0";
+ }
+ return $aFixIt;
+ }
+
+ /**
+ * Check that the values fit the allowed values
+ *
+ * @param $sClass
+ * @param $sAttCode
+ * @param \AttributeDefinition $oAttDef
+ * @param $sTable
+ * @param $sKeyField
+ * @param array $aErrorsAndFixes
+ *
+ * @throws \MySQLException
+ * @throws \Exception
+ */
+ private function CheckEnums($sClass, $sAttCode, AttributeDefinition $oAttDef, $sTable, $sKeyField, &$aErrorsAndFixes)
+ {
+ $aAllowedValues = MetaModel::GetAllowedValues_att($sClass, $sAttCode);
+ if (!is_null($aAllowedValues) && count($aAllowedValues) > 0)
+ {
+ $sExpectedValues = implode(",", CMDBSource::Quote(array_keys($aAllowedValues), true));
+
+ $aCols = $oAttDef->GetSQLExpressions(); // Workaround a PHP bug: sometimes issuing a Notice if invoking current(somefunc())
+ $sMyAttributeField = current($aCols); // get the first column for the moment
+ $sFilter = "FROM `$sTable` WHERE `$sTable`.`$sMyAttributeField` NOT IN ($sExpectedValues)";
+ $sDelete = "DELETE `$sTable`";
+ $sSelect = "SELECT DISTINCT `$sTable`.`$sKeyField` AS id, `$sTable`.`$sMyAttributeField` AS value";
+ $sSelWrongRecs = "$sSelect $sFilter";
+ $sFixItRequest = "$sDelete $sFilter";
+ $sErrorDesc = Dict::Format('DBAnalyzer-Integrity-InvalidValue', $sAttCode, $sTable, $sMyAttributeField);
+ $this->ExecQuery($sSelWrongRecs, $sFixItRequest, $sErrorDesc, $sClass, $aErrorsAndFixes);
+ // Fix it request needs the values of the enum to generate the requests
+ if (isset($aErrorsAndFixes[$sClass][$sErrorDesc]['values']))
+ {
+ if (isset($aErrorsAndFixes[$sClass][$sErrorDesc]['fixit']))
+ {
+ $aFixIt = $aErrorsAndFixes[$sClass][$sErrorDesc]['fixit'];
+ $aFixIt[] = "-- Alternative: Replace 'XXX' with the appropriate value";
+ }
+ else
+ {
+ $aFixIt = array("-- Replace 'XXX' with the appropriate value");
+ }
+ foreach (array_keys($aErrorsAndFixes[$sClass][$sErrorDesc]['values']) as $sKey)
+ {
+ $aFixIt[] = "UPDATE `$sTable` SET `$sTable`.`$sMyAttributeField` = 'XXX' WHERE `$sTable`.`$sMyAttributeField` = '$sKey'";
+ }
+ $aErrorsAndFixes[$sClass][$sErrorDesc]['fixit'] = $aFixIt;
+ }
+ }
+ }
+
+ /**
+ * @param $aErrorsAndFixes
+ *
+ * @throws \CoreException
+ * @throws \MySQLException
+ */
+ private function CheckUsers(&$aErrorsAndFixes)
+ {
+// Check user accounts without profile
+ $sUserTable = MetaModel::DBGetTable('User');
+ $sLinkTable = MetaModel::DBGetTable('URP_UserProfile');
+ $sSelect = "SELECT DISTINCT u.id AS id, u.`login` AS value";
+ $sFilter = "FROM `$sUserTable` AS u LEFT JOIN `$sLinkTable` AS l ON l.userid = u.id WHERE l.id IS NULL";
+ $sSelWrongRecs = "$sSelect $sFilter";
+ $sFixit = "-- Remove the corresponding user(s)";
+ $this->ExecQuery($sSelWrongRecs, $sFixit, Dict::S('DBAnalyzer-Integrity-UsersWithoutProfile'), 'User', $aErrorsAndFixes);
+ }
+
+
+}
diff --git a/datamodels/2.x/combodo-db-tools/dbtools.php b/datamodels/2.x/combodo-db-tools/dbtools.php
new file mode 100644
index 000000000..75744aced
--- /dev/null
+++ b/datamodels/2.x/combodo-db-tools/dbtools.php
@@ -0,0 +1,536 @@
+
+//
+
+@include_once('../../approot.inc.php');
+require_once(APPROOT.'application/startup.inc.php');
+
+require_once('db_analyzer.class.inc.php');
+
+const MAX_RESULTS = 10;
+
+/**
+ * @param iTopWebPage $oP
+ * @param ApplicationContext $oAppContext
+ *
+ * @return \iTopWebPage
+ * @throws CoreException
+ * @throws DictExceptionMissingString
+ * @throws MySQLException
+ */
+function DisplayDBInconsistencies(iTopWebPage &$oP, ApplicationContext &$oAppContext)
+{
+ $iShowId = intval(utils::ReadParam('show_id', '0'));
+ $sErrorLabelSelection = utils::ReadParam('error_selection', '');
+ $sClassSelection = utils::ReadParam('class_selection', '');
+ if (!empty($sClassSelection))
+ {
+ $aClassSelection = explode(",", $sClassSelection);
+ }
+ else
+ {
+ $aClassSelection = array();
+ }
+ $sClassSelection = utils::ReadParam('class_selection', '');
+
+ $oP->SetCurrentTab(Dict::S('DBTools:Inconsistencies'));
+
+ $bRunAnalysis = intval(utils::ReadParam('run_analysis', '0'));
+ if ($bRunAnalysis)
+ {
+ $oDBAnalyzer = new DatabaseAnalyzer();
+ $aResults = $oDBAnalyzer->CheckIntegrity($aClassSelection);
+ if (empty($aResults))
+ {
+ $oP->p('
');
+ }
+ }
+
+ $oP->add('');
+ $oP->add("
\n");
+ $oP->add('
');
+
+
+ if (!empty($sErrorLabelSelection) || !empty($sClassSelection))
+ {
+ $oP->add("
");
+ $oP->add("\n");
+ }
+
+ if (!empty($aResults))
+ {
+
+ if ($iShowId == 3)
+ {
+ DisplayInconsistenciesReport($aResults);
+ }
+
+ $oP->p(Dict::S('DBTools:ErrorsFound'));
+
+ $oP->add('| '.Dict::S('DBTools:Class').' | '.Dict::S('DBTools:Count').' | '.Dict::S('DBTools:Error').' |
');
+ $bTable = true;
+ foreach($aResults as $sClass => $aErrorList)
+ {
+ foreach($aErrorList as $sErrorLabel => $aError)
+ {
+ if (!empty($sErrorLabelSelection) && ($sErrorLabel != $sErrorLabelSelection))
+ {
+ continue;
+ }
+
+ if (!$bTable)
+ {
+ $oP->add('
');
+ $oP->add(' | Class | Count | Error |
');
+ $bTable = true;
+ }
+
+ $oP->add('');
+
+
+ $oP->add('| '.MetaModel::GetName($sClass).' ('.$sClass.') | ');
+ $iCount = $aError['count'];
+ $oP->add(''.$iCount.' | ');
+ $oP->add(''.$sErrorLabel.' | ');
+ $oP->add('
');
+
+ if ($iShowId > 0)
+ {
+ $oP->add('
');
+ $bTable = false;
+ $oP->p(Dict::S('DBTools:SQLquery'));
+ $sQuery = $aError['query'];
+ $oP->add('');
+ $oP->add(''.$sQuery.'');
+ $oP->add('
');
+
+ if (isset($aError['fixit']))
+ {
+ $oP->p(Dict::S('DBTools:FixitSQLquery'));
+ $aQueries = $aError['fixit'];
+ $oP->add('');
+ foreach($aQueries as $sFixQuery)
+ {
+ $oP->add('
'.$sFixQuery.'
');
+ }
+ $oP->add('
');
+ }
+
+ $oP->p(Dict::S('DBTools:SQLresult'));
+ $sQueryResult = '';
+ $iCount = count($aError['res']);
+ $iMaxCount = MAX_RESULTS;
+ foreach($aError['res'] as $aRes)
+ {
+ $iMaxCount--;
+ if ($iMaxCount < 0)
+ {
+ $sQueryResult .= 'Displayed '.MAX_RESULTS."/$iCount results.
";
+ break;
+ }
+ foreach($aRes as $sKey => $sValue)
+ {
+ $sQueryResult .= "'$sKey'='$sValue' ";
+ }
+ $sQueryResult .= '
';
+ }
+ $oP->add('');
+ $oP->add(''.$sQueryResult.'');
+ $oP->add('
');
+ }
+ }
+ }
+ $oP->add('
');
+ }
+ return $oP;
+}
+
+/**
+ * @param $aResults
+ *
+ * @return mixed
+ * @throws CoreException
+ * @throws DictExceptionMissingString
+ */
+function DisplayInconsistenciesReport($aResults)
+{
+ $sDBToolsFolder = str_replace("\\", '/', APPROOT.'log/');
+ $sReportFile = 'dbtools-report-'.date('Y-m-d-H-i-s');
+
+ $fReport = fopen($sDBToolsFolder.$sReportFile.'.txt', 'w');
+ fwrite($fReport, 'Database Maintenance tools: '.date('Y-m-d H:i:s')."\r\n");
+ foreach($aResults as $sClass => $aErrorList)
+ {
+ fwrite($fReport, '');
+ foreach($aErrorList as $sErrorLabel => $aError)
+ {
+ fwrite($fReport, "\r\n----------\r\n");
+ fwrite($fReport, 'Class: '.MetaModel::GetName($sClass).' ('.$sClass.")\r\n");
+ $iCount = $aError['count'];
+ fwrite($fReport, 'Count: '.$iCount."\r\n");
+ fwrite($fReport, 'Error: '.$sErrorLabel."\r\n");
+ $sQuery = $aError['query'];
+ fwrite($fReport, 'Query: '.$sQuery."\r\n");
+
+ if (isset($aError['fixit']))
+ {
+ fwrite($fReport, "\r\nFix it (indication):\r\n\r\n");
+ $aFixitQueries = $aError['fixit'];
+ foreach($aFixitQueries as $sFixitQuery)
+ {
+ fwrite($fReport, "$sFixitQuery\r\n");
+ }
+ fwrite($fReport, "\r\n");
+ }
+
+ $sQueryResult = '';
+ $aIdList = array();
+ foreach($aError['res'] as $aRes)
+ {
+ foreach($aRes as $sKey => $sValue)
+ {
+ $sQueryResult .= "'$sKey'='$sValue' ";
+ if ($sKey == 'id')
+ {
+ $aIdList[] = $sValue;
+ }
+ }
+ $sQueryResult .= "\r\n";
+
+ }
+ fwrite($fReport, "Result: \r\n".$sQueryResult);
+ $sIdList = '('.implode(',', $aIdList).')';
+ fwrite($fReport, 'Ids: '.$sIdList."\r\n");
+ }
+ }
+ fclose($fReport);
+
+ $oArchive = new ZipArchive();
+ $oArchive->open($sDBToolsFolder.$sReportFile.'.zip', ZipArchive::CREATE);
+ $oArchive->addFile($sDBToolsFolder.$sReportFile.'.txt', $sReportFile.'.txt');
+ $oArchive->close();
+ unlink($sDBToolsFolder.$sReportFile.'.txt');
+ $sReportFile = $sDBToolsFolder.$sReportFile.'.zip';
+
+
+ header('Content-Description: File Transfer');
+ header('Content-Type: multipart/x-zip');
+ header('Content-Disposition: inline; filename="'.basename($sReportFile).'"');
+ header('Expires: 0');
+ header('Cache-Control: must-revalidate');
+ header('Pragma: public');
+ header('Content-Length: '.filesize($sReportFile));
+ readfile($sReportFile);
+ unlink($sReportFile);
+ exit(0);
+}
+
+/**
+ * @param iTopWebPage $oP
+ * @param ApplicationContext $oAppContext
+ *
+ * @return \iTopWebPage
+ * @throws CoreException
+ * @throws MySQLException
+ * @throws \Exception
+ */
+function DisplayLostAttachments(iTopWebPage &$oP, ApplicationContext &$oAppContext)
+{
+ // Retrieve parameters
+ $sStepName = utils::ReadParam('step_name');
+ $aRecordsToClean = utils::ReadParam('dbt-cbx', array(), false, 'raw_data');
+
+ $iRestoredItemsCount = 0;
+ $iRecordsToCleanCount = count($aRecordsToClean);
+ $aErrorsReport = array();
+
+ $bDoAnalyze = in_array($sStepName, array('analyze', 'restore'));
+ $bDoRestore = in_array($sStepName, array('restore'));
+
+ // Build HTML
+ $oP->SetCurrentTab(Dict::S('DBTools:LostAttachments'));
+
+ $oP->add('');
+ $oP->add('
');
+
+ $oP->add('');
+ $oP->add('
');
+ $oP->add('
');
+ $oP->add('
');
+
+ $oP->add('
');
+ $oP->add('
');
+
+ // Buttons disabling on click
+ $sConfirmText = Dict::S('DBTools:LostAttachments:Button:Restore:Confirm');
+ $sButtonBusyText = Dict::S('DBTools:LostAttachments:Button:Busy');
+ $oP->add_ready_script(
+<<add_saas('env-'.utils::GetCurrentEnvironment().'/combodo-db-tools/default.scss');
+
+ $oP->add(
+<<