From d4c16bd161f4dd2955421c7fc033738b58ac042c Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Tue, 3 Mar 2026 20:18:22 +0530 Subject: [PATCH] Harden schema sync index alters with equivalent-index checks --- app/Controllers/Setup.php | 116 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/app/Controllers/Setup.php b/app/Controllers/Setup.php index 0525f14..7fe21a8 100644 --- a/app/Controllers/Setup.php +++ b/app/Controllers/Setup.php @@ -853,11 +853,27 @@ class Setup extends BaseController foreach ($masterIdx as $keyName => $masterDef) { if (!isset($clientIdx[$keyName])) { + if ($this->hasEquivalentIndexDefinition($clientIdx, (array) $masterDef)) { + continue; + } + + if ($this->shouldSkipUniqueIndexSync($client, $table, (array) $masterDef, $clientCols)) { + continue; + } + $sql[] = 'ALTER TABLE `' . $table . '` ADD ' . $this->indexDefinitionSql($masterDef) . ';'; continue; } if ($this->indexSignature($masterDef) !== $this->indexSignature($clientIdx[$keyName])) { + if ($this->hasEquivalentIndexDefinition($clientIdx, (array) $masterDef, $keyName)) { + continue; + } + + if ($this->shouldSkipUniqueIndexSync($client, $table, (array) $masterDef, $clientCols)) { + continue; + } + if ($keyName === 'PRIMARY') { $sql[] = 'ALTER TABLE `' . $table . '` DROP PRIMARY KEY;'; } else { @@ -870,6 +886,49 @@ class Setup extends BaseController return $sql; } + /** + * @param array> $clientIdx + * @param array $masterDef + */ + private function hasEquivalentIndexDefinition(array $clientIdx, array $masterDef, ?string $ignoreKeyName = null): bool + { + $masterCols = array_values((array) ($masterDef['columns'] ?? [])); + $masterType = strtoupper((string) ($masterDef['type'] ?? 'BTREE')); + $masterUnique = (bool) ($masterDef['unique'] ?? false); + $masterName = (string) ($masterDef['name'] ?? ''); + $masterIsPrimary = strtoupper($masterName) === 'PRIMARY'; + + if ($masterIsPrimary) { + $masterUnique = true; + } + + if (empty($masterCols)) { + return false; + } + + foreach ($clientIdx as $clientKeyName => $clientDef) { + if ($ignoreKeyName !== null && strcasecmp((string) $clientKeyName, $ignoreKeyName) === 0) { + continue; + } + + $clientCols = array_values((array) ($clientDef['columns'] ?? [])); + $clientType = strtoupper((string) ($clientDef['type'] ?? 'BTREE')); + $clientUnique = (bool) ($clientDef['unique'] ?? false); + $clientName = (string) ($clientDef['name'] ?? ''); + $clientIsPrimary = strtoupper($clientName) === 'PRIMARY'; + + if ($clientIsPrimary) { + $clientUnique = true; + } + + if ($masterCols === $clientCols && $masterType === $clientType && $masterUnique === $clientUnique) { + return true; + } + } + + return false; + } + private function connectSyncDb(string $role) { $role = strtolower(trim($role)); @@ -1115,9 +1174,66 @@ class Setup extends BaseController return true; } + if (str_contains($messageLower, 'duplicate entry') && str_contains($stmtLower, 'alter table') && (str_contains($stmtLower, 'add unique key') || str_contains($stmtLower, 'add primary key'))) { + return true; + } + return false; } + /** + * @param array $idxDef + * @param array> $clientCols + */ + private function shouldSkipUniqueIndexSync($db, string $table, array $idxDef, array $clientCols): bool + { + $isPrimary = strtoupper((string) ($idxDef['name'] ?? '')) === 'PRIMARY'; + $isUnique = $isPrimary || (bool) ($idxDef['unique'] ?? false); + if (!$isUnique) { + return false; + } + + $columns = array_values(array_filter(array_map(static fn ($col) => (string) $col, (array) ($idxDef['columns'] ?? [])))); + if (empty($columns)) { + return false; + } + + $safeTable = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; + if ($safeTable === '') { + return false; + } + + $safeColumns = []; + $notNullConditions = []; + foreach ($columns as $column) { + $safeColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $column) ?? ''; + if ($safeColumn === '') { + return false; + } + $safeColumns[] = '`' . $safeColumn . '`'; + + $colMeta = $clientCols[$safeColumn] ?? null; + $isNullable = strtoupper((string) ($colMeta['Null'] ?? 'YES')) !== 'NO'; + if ($isNullable) { + $notNullConditions[] = '`' . $safeColumn . '` IS NOT NULL'; + } + } + + $groupBy = implode(', ', $safeColumns); + $sql = 'SELECT 1 FROM `' . $safeTable . '`'; + if (!empty($notNullConditions)) { + $sql .= ' WHERE ' . implode(' AND ', $notNullConditions); + } + $sql .= ' GROUP BY ' . $groupBy . ' HAVING COUNT(*) > 1 LIMIT 1'; + + try { + $row = $db->query($sql)->getRowArray(); + return !empty($row); + } catch (\Throwable $e) { + return false; + } + } + private function stripForeignKeysFromCreateTable(string $stmt): string { $out = $stmt;