diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index f8d480bc8c..6f8b182504 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -229,6 +229,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade // - 8.15 Final - Current state: {5C424554-A32D-4852-8ED1-A13508187901} // - 9.0 RC1 - Current state: {5060F3D2-88BE-4D30-8755-CF51F28EAD12} To("{622E5172-42E1-4662-AD80-9504AF5A4E53}"); + To("{12DCDE7F-9AB7-4617-804F-AB66BF360980}"); } } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/DictionaryTablesIndexes.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/DictionaryTablesIndexes.cs new file mode 100644 index 0000000000..91a8b29bbb --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/DictionaryTablesIndexes.cs @@ -0,0 +1,136 @@ +using System.Linq; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Infrastructure.Migrations.Expressions.Execute.Expressions; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 +{ + public class DictionaryTablesIndexes : MigrationBase + { + private const string IndexedDictionaryColumn = "key"; + private const string IndexedLanguageTextColumn = "value"; + + public DictionaryTablesIndexes(IMigrationContext context) + : base(context) + { + } + + protected override void Migrate() + { + var indexDictionaryDto = $"IX_{DictionaryDto.TableName}_{IndexedDictionaryColumn}"; + var indexLanguageTextDto = $"IX_{LanguageTextDto.TableName}_{IndexedLanguageTextColumn}"; + + // Delete existing + DeleteIndex(indexDictionaryDto); + // Re-create/Add + AddUniqueConstraint(new[] { IndexedDictionaryColumn }, indexDictionaryDto); + + // Delete existing + DeleteIndex(indexLanguageTextDto); + + var langTextcolumns = new[] { "languageId", "UniqueId", IndexedLanguageTextColumn }; + + // Re-create/Add + AddUniqueConstraint(langTextcolumns, indexLanguageTextDto); + } + + private void DeleteIndex(string indexName) + { + var tableDef = DefinitionFactory.GetTableDefinition(typeof(TDto), Context.SqlContext.SqlSyntax); + + if (IndexExists(indexName)) + { + Delete.Index(indexName).OnTable(tableDef.Name).Do(); + } + } + + private void CreateIndex(string indexName) + { + var tableDef = DefinitionFactory.GetTableDefinition(typeof(TDto), Context.SqlContext.SqlSyntax); + + // get the definition by name + var index = tableDef.Indexes.First(x => x.Name == indexName); + new ExecuteSqlStatementExpression(Context) { SqlStatement = Context.SqlContext.SqlSyntax.Format(index) }.Execute(); + } + + private void AddUniqueConstraint(string[] columns, string index) + { + var tableDef = DefinitionFactory.GetTableDefinition(typeof(TDto), Context.SqlContext.SqlSyntax); + + // Check the existing data to ensure the constraint can be successfully applied. + // This seems to be better than relying on catching an exception as this leads to + // transaction errors: "This SqlTransaction has completed; it is no longer usable". + var columnsDescription = string.Join("], [", columns); + if (ContainsDuplicates(columns)) + { + var message = $"Could not create unique constraint on [{tableDef.Name}] due to existing " + + $"duplicate records across the column{(columns.Length > 1 ? "s" : string.Empty)}: [{columnsDescription}]."; + + LogIncompleteMigrationStep(message); + return; + } + + CreateIndex(index); + } + + private bool ContainsDuplicates(string[] columns) + { + // Check for duplicates by comparing the total count of all records with the count of records distinct by the + // provided column. If the former is greater than the latter, there's at least one duplicate record. + int recordCount = GetRecordCount(); + int distinctRecordCount = GetDistinctRecordCount(columns); + + return recordCount > distinctRecordCount; + } + + private int GetRecordCount() + { + var countQuery = Database.SqlContext.Sql() + .SelectCount() + .From(); + + return Database.ExecuteScalar(countQuery); + } + + private int GetDistinctRecordCount(string[] columns) + { + string columnSpecification; + + // If using SQL CE, we don't have access to COUNT (DISTINCT *) or CONCAT, so will need to do this by querying all records. + if (DatabaseType.IsSqlCe()) + { + columnSpecification = columns.Length == 1 + ? StringConvertedAndQuotedColumnName(columns[0]) + : $"{string.Join(" + ", columns.Select(x => StringConvertedAndQuotedColumnName(x)))}"; + + var allRecordsQuery = Database.SqlContext.Sql() + .Select(columnSpecification) + .From(); + + var allRecords = Database.Fetch(allRecordsQuery); + + return allRecords.Distinct().Count(); + } + + columnSpecification = columns.Length == 1 + ? QuoteColumnName(columns[0]) + : $"CONCAT({string.Join(",", columns.Select(QuoteColumnName))})"; + + var distinctCountQuery = Database.SqlContext.Sql() + .Select($"COUNT(DISTINCT({columnSpecification}))") + .From(); + + return Database.ExecuteScalar(distinctCountQuery); + } + + private void LogIncompleteMigrationStep(string message) => + Logger.LogError($"Database migration step failed: {message}"); + + private string StringConvertedAndQuotedColumnName(string column) => $"CONVERT(nvarchar(1000),{QuoteColumnName(column)})"; + + private string QuoteColumnName(string column) => $"[{column}]"; + } +}