using System; using System.Collections.Generic; using System.Data.OleDb; using System.Globalization; using System.Linq; using Microsoft.Data.SqlClient; using Serilog; using SyncEngine.Access; using SyncEngine.Configuration; namespace SyncEngine.SqlServer { /// /// Handles unique secondary-key collisions after INSERT error 2601/2627. /// Must not be called on the happy path (no per-row probes). /// internal sealed class UniqueKeyCollisionResolver { private readonly ILogger _logger; private readonly int _commandTimeoutSeconds; public UniqueKeyCollisionResolver(ILogger logger, int commandTimeoutSeconds) { _logger = logger; _commandTimeoutSeconds = commandTimeoutSeconds; } public static bool IsUniqueIndexViolation(SqlException ex) { if (ex == null) { return false; } if (UniqueKeyCollisionClassifier.IsUniqueIndexViolation(ex.Number)) { return true; } foreach (SqlError err in ex.Errors) { if (UniqueKeyCollisionClassifier.IsUniqueIndexViolation(err.Number)) { return true; } } return false; } public bool TryResolve( SqlConnection sqlConn, SqlTransaction tx, OleDbConnection accessConn, string qualifiedTable, AccessTableSchema table, IList pkColumns, IList uniqueIndexes, Dictionary incomingRow) { if (uniqueIndexes == null || uniqueIndexes.Count == 0 || pkColumns == null || pkColumns.Count == 0) { return false; } EnsureTransactionUsable(sqlConn, tx); string uniqueCol = null; Dictionary occupyingSql = null; foreach (var index in uniqueIndexes) { var col = index.Columns[0]; object incomingValue; if (!incomingRow.TryGetValue(col, out incomingValue) || incomingValue == null || incomingValue is DBNull) { continue; } occupyingSql = FindSqlRowByUnique(sqlConn, tx, qualifiedTable, pkColumns, table, col, incomingValue); if (occupyingSql != null) { uniqueCol = col; break; } } if (occupyingSql == null || uniqueCol == null) { return false; } var occupyingPkKey = DataSyncPrimaryKeyHelper.BuildPkKey(occupyingSql, pkColumns); var incomingPkKey = DataSyncPrimaryKeyHelper.BuildPkKey(incomingRow, pkColumns); if (string.Equals(occupyingPkKey, incomingPkKey, StringComparison.OrdinalIgnoreCase)) { return false; } var accessRow = FindAccessRowByPk(accessConn, table, pkColumns, occupyingSql); var occupyingExistsInAccess = accessRow != null; object accessCodeObj = null; var occupyingAccessCode = occupyingExistsInAccess && accessRow.TryGetValue(uniqueCol, out accessCodeObj) ? Convert.ToString(accessCodeObj, CultureInfo.InvariantCulture) : null; object incomingCodeObj; incomingRow.TryGetValue(uniqueCol, out incomingCodeObj); var incomingCode = Convert.ToString(incomingCodeObj, CultureInfo.InvariantCulture); var occupyingDescription = ReadDescription(occupyingSql); var incomingDescription = ReadDescription(incomingRow); var action = UniqueKeyCollisionClassifier.Classify( occupyingExistsInAccess, occupyingAccessCode, incomingCode, occupyingDescription, incomingDescription); _logger.Information( "Unique {Column} collision on {Table}: incoming PK {IncomingPk} occupying PK {OccupyingPk} action={Action}", uniqueCol, table.TableName, incomingPkKey, occupyingPkKey, action); switch (action) { case UniqueKeyCollisionAction.FailDuplicate: throw new InvalidOperationException(string.Format( CultureInfo.InvariantCulture, "Access has two rows with {0}={1} (incoming PK {2}, occupying PK {3}).", uniqueCol, incomingCode, incomingPkKey, occupyingPkKey)); case UniqueKeyCollisionAction.StaleUpdateOccupying: if (accessRow == null) { return false; } UpdateUniqueColumn(sqlConn, tx, qualifiedTable, pkColumns, occupyingSql, uniqueCol, accessCodeObj); return true; case UniqueKeyCollisionAction.Remap: RenameOccupyingUnique(sqlConn, tx, qualifiedTable, pkColumns, occupyingSql, uniqueCol, incomingCode, occupyingPkKey, table); RetargetChildren(sqlConn, tx, table, pkColumns, occupyingSql, incomingRow); return true; case UniqueKeyCollisionAction.Recycle: RenameOccupyingUnique(sqlConn, tx, qualifiedTable, pkColumns, occupyingSql, uniqueCol, incomingCode, occupyingPkKey, table); return true; default: return false; } } private void EnsureTransactionUsable(SqlConnection sqlConn, SqlTransaction tx) { using (var cmd = new SqlCommand("SELECT XACT_STATE()", sqlConn, tx)) { cmd.CommandTimeout = _commandTimeoutSeconds; var state = Convert.ToInt32(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); if (state == -1) { throw new InvalidOperationException( "SQL transaction is unusable after unique-index error (XACT_STATE = -1)."); } } } private Dictionary FindSqlRowByUnique( SqlConnection sqlConn, SqlTransaction tx, string qualifiedTable, IList pkColumns, AccessTableSchema table, string uniqueCol, object uniqueValue) { var selectCols = new List(pkColumns); if (!selectCols.Contains(uniqueCol, StringComparer.OrdinalIgnoreCase)) { selectCols.Add(uniqueCol); } if (HasColumn(table, "strDescription") && !selectCols.Contains("strDescription", StringComparer.OrdinalIgnoreCase)) { selectCols.Add("strDescription"); } var sql = string.Format( CultureInfo.InvariantCulture, "SELECT TOP 1 {0} FROM {1} WHERE [{2}] = @v", string.Join(", ", selectCols.Select(c => "[" + c + "]")), qualifiedTable, uniqueCol); using (var cmd = new SqlCommand(sql, sqlConn, tx)) { cmd.CommandTimeout = _commandTimeoutSeconds; cmd.Parameters.AddWithValue("@v", uniqueValue); using (var reader = cmd.ExecuteReader()) { if (!reader.Read()) { return null; } var row = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < reader.FieldCount; i++) { row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i); } return row; } } } private static Dictionary FindAccessRowByPk( OleDbConnection accessConn, AccessTableSchema table, IList pkColumns, Dictionary occupyingSql) { if (accessConn == null) { return null; } var predicates = new List(); for (var i = 0; i < pkColumns.Count; i++) { predicates.Add("[" + pkColumns[i] + "] = ?"); } var sql = string.Format( CultureInfo.InvariantCulture, "SELECT TOP 1 * FROM [{0}] WHERE {1}", table.TableName, string.Join(" AND ", predicates)); using (var cmd = new OleDbCommand(sql, accessConn)) { cmd.CommandTimeout = 30; for (var i = 0; i < pkColumns.Count; i++) { object value; occupyingSql.TryGetValue(pkColumns[i], out value); cmd.Parameters.AddWithValue("p" + i, value ?? DBNull.Value); } using (var reader = cmd.ExecuteReader()) { if (!reader.Read()) { return null; } var row = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < reader.FieldCount; i++) { row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i); } return row; } } } private void RenameOccupyingUnique( SqlConnection sqlConn, SqlTransaction tx, string qualifiedTable, IList pkColumns, Dictionary occupyingSql, string uniqueCol, string originalCode, string occupyingPkKey, AccessTableSchema table) { var maxLen = UniqueColumnMaxLength(table, uniqueCol); var renamed = UniqueKeyCollisionClassifier.BuildRemovedCode(originalCode, maxLen, occupyingPkKey); try { UpdateUniqueColumn(sqlConn, tx, qualifiedTable, pkColumns, occupyingSql, uniqueCol, renamed); } catch (SqlException ex) when (IsUniqueIndexViolation(ex)) { var fallback = UniqueKeyCollisionClassifier.BuildPkFallback(maxLen, occupyingPkKey); UpdateUniqueColumn(sqlConn, tx, qualifiedTable, pkColumns, occupyingSql, uniqueCol, fallback); } } private void UpdateUniqueColumn( SqlConnection sqlConn, SqlTransaction tx, string qualifiedTable, IList pkColumns, Dictionary occupyingSql, string uniqueCol, object newValue) { var where = DataSyncPrimaryKeyHelper.BuildSqlWhereClause(pkColumns, "@pk"); var sql = string.Format( CultureInfo.InvariantCulture, "UPDATE {0} SET [{1}] = @v WHERE {2}", qualifiedTable, uniqueCol, where); using (var cmd = new SqlCommand(sql, sqlConn, tx)) { cmd.CommandTimeout = _commandTimeoutSeconds; cmd.Parameters.AddWithValue("@v", newValue ?? (object)DBNull.Value); DataSyncPrimaryKeyHelper.BindPrimaryKeyParameters(cmd, occupyingSql, pkColumns, "@pk"); cmd.ExecuteNonQuery(); } } private void RetargetChildren( SqlConnection sqlConn, SqlTransaction tx, AccessTableSchema table, IList pkColumns, Dictionary occupyingSql, Dictionary incomingRow) { if (pkColumns.Count != 1) { return; } var pkCol = pkColumns[0]; var refCol = UniqueKeyCollisionClassifier.BuildRefColumnName(pkCol); object oldId; object newId; occupyingSql.TryGetValue(pkCol, out oldId); incomingRow.TryGetValue(pkCol, out newId); if (oldId == null || newId == null) { return; } const string findSql = @" SELECT QUOTENAME(SCHEMA_NAME(t.schema_id)) + '.' + QUOTENAME(t.name) AS QualifiedTable FROM sys.columns c INNER JOIN sys.tables t ON c.object_id = t.object_id WHERE t.is_ms_shipped = 0 AND c.name = @col AND t.name <> @skipTable"; var children = new List(); using (var cmd = new SqlCommand(findSql, sqlConn, tx)) { cmd.CommandTimeout = _commandTimeoutSeconds; cmd.Parameters.AddWithValue("@col", refCol); cmd.Parameters.AddWithValue("@skipTable", table.TableName); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { children.Add(reader.GetString(0)); } } } foreach (var child in children) { var sql = string.Format( CultureInfo.InvariantCulture, "UPDATE {0} SET [{1}] = @newId WHERE [{1}] = @oldId", child, refCol); using (var cmd = new SqlCommand(sql, sqlConn, tx)) { cmd.CommandTimeout = _commandTimeoutSeconds; cmd.Parameters.AddWithValue("@newId", newId); cmd.Parameters.AddWithValue("@oldId", oldId); var count = cmd.ExecuteNonQuery(); if (count > 0) { _logger.Information( "Retargeted {Count} {Child}.{RefCol} rows from {OldId} to {NewId}", count, child, refCol, oldId, newId); } } } } private static string ReadDescription(IReadOnlyDictionary row) { object value; if (row != null && row.TryGetValue("strDescription", out value) && value != null && !(value is DBNull)) { return Convert.ToString(value, CultureInfo.InvariantCulture); } return null; } private static bool HasColumn(AccessTableSchema table, string name) { return table?.Columns != null && table.Columns.Any(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); } private static int UniqueColumnMaxLength(AccessTableSchema table, string uniqueCol) { if (table?.Columns == null) { return 20; } foreach (var col in table.Columns) { if (string.Equals(col.Name, uniqueCol, StringComparison.OrdinalIgnoreCase) && col.ColumnSize > 0) { return col.ColumnSize; } } return 20; } } }