From 99e260ff5b522389551f7f3f7537e41c4db81a28 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:01:14 +0200 Subject: [PATCH 1/3] Adjusted the UTC SQL Server migration to convert time zone ids to the correct format --- .../SystemDateMigrationSettingsValidator.cs | 30 --- .../UmbracoBuilder.Configuration.cs | 1 - .../V_17_0_0/MigrateSystemDatesToUtc.cs | 172 ++++++++++++------ ...stemDateMigrationSettingsValidatorTests.cs | 47 ----- 4 files changed, 116 insertions(+), 134 deletions(-) delete mode 100644 src/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidator.cs delete mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidatorTests.cs diff --git a/src/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidator.cs b/src/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidator.cs deleted file mode 100644 index a4686663d7f6..000000000000 --- a/src/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidator.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Umbraco. -// See LICENSE for more details. - -using Microsoft.Extensions.Options; - -namespace Umbraco.Cms.Core.Configuration.Models.Validation; - -/// -/// Validator for configuration representated as . -/// -public class SystemDateMigrationSettingsValidator - : IValidateOptions -{ - /// - public ValidateOptionsResult Validate(string? name, SystemDateMigrationSettings options) - { - if (string.IsNullOrWhiteSpace(options.LocalServerTimeZone)) - { - return ValidateOptionsResult.Success; - } - - if (TimeZoneInfo.TryFindSystemTimeZoneById(options.LocalServerTimeZone, out _) is false) - { - return ValidateOptionsResult.Fail( - $"Configuration entry {Constants.Configuration.ConfigSystemDateMigration} contains an invalid time zone: {options.LocalServerTimeZone}."); - } - - return ValidateOptionsResult.Success; - } -} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 52d136e75326..db3c8ef2b34c 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -47,7 +47,6 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder) builder.Services.AddSingleton, RequestHandlerSettingsValidator>(); builder.Services.AddSingleton, UnattendedSettingsValidator>(); builder.Services.AddSingleton, SecuritySettingsValidator>(); - builder.Services.AddSingleton, SystemDateMigrationSettingsValidator>(); // Register configuration sections. builder diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs index 3f17c4d6e47a..cd3fda617917 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs @@ -1,8 +1,9 @@ -using NPoco; -using Umbraco.Cms.Infrastructure.Scoping; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using NPoco; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0; @@ -11,6 +12,8 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0; /// public class MigrateSystemDatesToUtc : UnscopedMigrationBase { + private static readonly string[] UtcIdentifiers = ["Coordinated Universal Time", "UTC"]; + private readonly IScopeProvider _scopeProvider; private readonly TimeProvider _timeProvider; private readonly IOptions _migrationSettings; @@ -49,96 +52,153 @@ protected override void Migrate() return; } - // If the local server timezone is not set, we detect it. - var timeZoneName = _migrationSettings.Value.LocalServerTimeZone; - if (string.IsNullOrWhiteSpace(timeZoneName)) + // Offsets and Windows name are lazy loaded as they are not always needed. + // This also allows using timezones that exist in the SQL Database but not on the local server. + (string TimeZoneName, Lazy TimeZoneOffset, Lazy WindowsTimeZoneName) timeZone; + + var configuredTimeZoneName = _migrationSettings.Value.LocalServerTimeZone; + if (!configuredTimeZoneName.IsNullOrWhiteSpace()) { - timeZoneName = _timeProvider.LocalTimeZone.Id; - _logger.LogInformation("Migrating system dates to UTC using the detected local server timezone: {TimeZoneName}.", timeZoneName); + timeZone = ( + configuredTimeZoneName, + new Lazy(() => TimeZoneInfo.FindSystemTimeZoneById(configuredTimeZoneName).BaseUtcOffset), + new Lazy(() => configuredTimeZoneName)); + + _logger.LogInformation( + "Migrating system dates to UTC using the configured timezone: {TimeZoneName}.", + timeZone.TimeZoneName); } else { - _logger.LogInformation("Migrating system dates to UTC using the configured local server timezone: {TimeZoneName}.", timeZoneName); + // If the local server timezone is not configured, we detect it. + TimeZoneInfo timeZoneInfo = _timeProvider.LocalTimeZone; + + timeZone = ( + timeZoneInfo.Id, + new Lazy(() => timeZoneInfo.BaseUtcOffset), + new Lazy(() => GetWindowsTimeZoneId(timeZoneInfo))); + + _logger.LogInformation( + "Migrating system dates to UTC using the detected local server timezone: {TimeZoneName}.", + timeZone.TimeZoneName); } // If the local server timezone is UTC, skip the migration. - if (string.Equals(timeZoneName, "Coordinated Universal Time", StringComparison.OrdinalIgnoreCase)) + if (UtcIdentifiers.Contains(timeZone.TimeZoneName, StringComparer.OrdinalIgnoreCase)) { - _logger.LogInformation("Skipping migration {MigrationName} as the local server timezone is UTC.", nameof(MigrateSystemDatesToUtc)); + _logger.LogInformation( + "Skipping migration {MigrationName} as the local server timezone is UTC.", + nameof(MigrateSystemDatesToUtc)); Context.Complete(); return; } - TimeSpan timeZoneOffset = GetTimezoneOffset(timeZoneName); - using IScope scope = _scopeProvider.CreateScope(); using IDisposable notificationSuppression = scope.Notifications.Suppress(); - MigrateDateColumn(scope, "cmsMember", "emailConfirmedDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "cmsMember", "lastLoginDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "cmsMember", "lastLockoutDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "cmsMember", "lastPasswordChangeDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoAccess", "createDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoAccess", "updateDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoAccessRule", "createDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoAccessRule", "updateDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoCreatedPackageSchema", "updateDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoContentVersion", "versionDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoContentVersionCleanupPolicy", "updated", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoContentVersionCultureVariation", "date", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoExternalLogin", "createDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoExternalLoginToken", "createDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoKeyValue", "updated", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoLog", "Datestamp", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoNode", "createDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoRelation", "datetime", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoServer", "registeredDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoServer", "lastNotifiedDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUser", "createDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUser", "updateDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUser", "emailConfirmedDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUser", "lastLockoutDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUser", "lastPasswordChangeDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUser", "lastLoginDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUser", "invitedDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUserGroup", "createDate", timeZoneName, timeZoneOffset); - MigrateDateColumn(scope, "umbracoUserGroup", "updateDate", timeZoneName, timeZoneOffset); + MigrateDateColumn(scope, "cmsMember", "emailConfirmedDate", timeZone); + MigrateDateColumn(scope, "cmsMember", "lastLoginDate", timeZone); + MigrateDateColumn(scope, "cmsMember", "lastLockoutDate", timeZone); + MigrateDateColumn(scope, "cmsMember", "lastPasswordChangeDate", timeZone); + MigrateDateColumn(scope, "umbracoAccess", "createDate", timeZone); + MigrateDateColumn(scope, "umbracoAccess", "updateDate", timeZone); + MigrateDateColumn(scope, "umbracoAccessRule", "createDate", timeZone); + MigrateDateColumn(scope, "umbracoAccessRule", "updateDate", timeZone); + MigrateDateColumn(scope, "umbracoCreatedPackageSchema", "updateDate", timeZone); + MigrateDateColumn(scope, "umbracoContentVersion", "versionDate", timeZone); + MigrateDateColumn(scope, "umbracoContentVersionCleanupPolicy", "updated", timeZone); + MigrateDateColumn(scope, "umbracoContentVersionCultureVariation", "date", timeZone); + MigrateDateColumn(scope, "umbracoExternalLogin", "createDate", timeZone); + MigrateDateColumn(scope, "umbracoExternalLoginToken", "createDate", timeZone); + MigrateDateColumn(scope, "umbracoKeyValue", "updated", timeZone); + MigrateDateColumn(scope, "umbracoLog", "Datestamp", timeZone); + MigrateDateColumn(scope, "umbracoNode", "createDate", timeZone); + MigrateDateColumn(scope, "umbracoRelation", "datetime", timeZone); + MigrateDateColumn(scope, "umbracoServer", "registeredDate", timeZone); + MigrateDateColumn(scope, "umbracoServer", "lastNotifiedDate", timeZone); + MigrateDateColumn(scope, "umbracoUser", "createDate", timeZone); + MigrateDateColumn(scope, "umbracoUser", "updateDate", timeZone); + MigrateDateColumn(scope, "umbracoUser", "emailConfirmedDate", timeZone); + MigrateDateColumn(scope, "umbracoUser", "lastLockoutDate", timeZone); + MigrateDateColumn(scope, "umbracoUser", "lastPasswordChangeDate", timeZone); + MigrateDateColumn(scope, "umbracoUser", "lastLoginDate", timeZone); + MigrateDateColumn(scope, "umbracoUser", "invitedDate", timeZone); + MigrateDateColumn(scope, "umbracoUserGroup", "createDate", timeZone); + MigrateDateColumn(scope, "umbracoUserGroup", "updateDate", timeZone); scope.Complete(); Context.Complete(); } - private static TimeSpan GetTimezoneOffset(string timeZoneName) + private static string GetWindowsTimeZoneId(TimeZoneInfo timeZone) + { + if (!timeZone.HasIanaId) + { + return timeZone.Id; + } - // We know the provided timezone name exists, as it's either detected or configured (and configuration has been validated). - => TimeZoneInfo.FindSystemTimeZoneById(timeZoneName).BaseUtcOffset; + if (!TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZone.Id, out var windowsId)) + { + throw new InvalidOperationException( + $"Failed to convert local time zone IANA id '{timeZone.Id}' to a Windows id. Please manually configure the 'Umbraco:CMS:SystemDateMigration:LocalServerTimeZone' app setting with a valid Windows time zone name."); + } - private void MigrateDateColumn(IScope scope, string tableName, string columName, string timezoneName, TimeSpan timeZoneOffset) - { - var offsetInMinutes = -timeZoneOffset.TotalMinutes; - var offSetInMinutesString = offsetInMinutes > 0 - ? $"+{offsetInMinutes}" - : $"{offsetInMinutes}"; + return windowsId; + } - Sql sql; + private void MigrateDateColumn( + IScope scope, + string tableName, + string columName, + (string Name, Lazy BaseOffset, Lazy WindowsName) timeZone) + { if (DatabaseType == DatabaseType.SQLite) { - // SQLite does not support AT TIME ZONE, but we can use the offset to update the dates. It won't take account of daylight saving time, but - // given these are historical dates in expected non-production environments, that are unlikely to be necessary to be 100% accurate, this is acceptable. - sql = Sql($"UPDATE {tableName} SET {columName} = DATETIME({columName}, '{offSetInMinutesString} minutes')"); + MigrateDateColumnSQLite(scope, tableName, columName, timeZone.Name, timeZone.BaseOffset.Value); } else { - sql = Sql($"UPDATE {tableName} SET {columName} = {columName} AT TIME ZONE '{timezoneName}' AT TIME ZONE 'UTC'"); + MigrateDateColumnSqlServer(scope, tableName, columName, timeZone.WindowsName.Value); } + } + + private void MigrateDateColumnSQLite( + IScope scope, + string tableName, + string columName, + string timezoneName, + TimeSpan timezoneOffset) + { + // SQLite does not support AT TIME ZONE, but we can use the offset to update the dates. It won't take account of daylight saving time, but + // given these are historical dates in expected non-production environments, that are unlikely to be necessary to be 100% accurate, this is acceptable. + + var offsetInMinutes = -timezoneOffset.TotalMinutes; + var offSetInMinutesString = offsetInMinutes > 0 + ? $"+{offsetInMinutes}" + : $"{offsetInMinutes}"; + + Sql sql = Sql($"UPDATE {tableName} SET {columName} = DATETIME({columName}, '{offSetInMinutesString} minutes')"); scope.Database.Execute(sql); _logger.LogInformation( - "Migrated {TableName}.{ColumnName} from local server timezone of {TimeZoneName} ({OffSetInMinutes} minutes) to UTC.", + "Migrated {TableName}.{ColumnName} from timezone {TimeZoneName} ({OffSetInMinutes}) to UTC.", tableName, columName, timezoneName, offSetInMinutesString); } + + private void MigrateDateColumnSqlServer(IScope scope, string tableName, string columName, string timeZoneName) + { + Sql sql = Sql($"UPDATE {tableName} SET {columName} = {columName} AT TIME ZONE '{timeZoneName}' AT TIME ZONE 'UTC'"); + + scope.Database.Execute(sql); + + _logger.LogInformation( + "Migrated {TableName}.{ColumnName} from timezone {TimeZoneName} to UTC.", + tableName, + columName, + timeZoneName); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidatorTests.cs deleted file mode 100644 index 23565db4f150..000000000000 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/SystemDateMigrationSettingsValidatorTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Umbraco. -// See LICENSE for more details. - -using Microsoft.Extensions.Options; -using NUnit.Framework; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Configuration.Models.Validation; - -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models.Validation -{ - [TestFixture] - public class SystemDateMigrationSettingsValidatorTests - { - [Test] - public void Returns_Success_For_Empty_Configuration() - { - var validator = new SystemDateMigrationSettingsValidator(); - SystemDateMigrationSettings options = BuildSystemDateMigrationSettings(); - ValidateOptionsResult result = validator.Validate("settings", options); - Assert.True(result.Succeeded); - } - - [Test] - public void Returns_Success_For_Valid_Configuration() - { - var validator = new SystemDateMigrationSettingsValidator(); - SystemDateMigrationSettings options = BuildSystemDateMigrationSettings(localServerTimeZone: "Central European Standard Time"); - ValidateOptionsResult result = validator.Validate("settings", options); - Assert.True(result.Succeeded); - } - - [Test] - public void Returns_Fail_For_Configuration_With_Invalid_LocalServerTimeZone() - { - var validator = new SystemDateMigrationSettingsValidator(); - SystemDateMigrationSettings options = BuildSystemDateMigrationSettings(localServerTimeZone: "Invalid Time Zone"); - ValidateOptionsResult result = validator.Validate("settings", options); - Assert.False(result.Succeeded); - } - - private static SystemDateMigrationSettings BuildSystemDateMigrationSettings(string? localServerTimeZone = null) => - new SystemDateMigrationSettings - { - LocalServerTimeZone = localServerTimeZone, - }; - } -} From 1cfa78e4ba44992f3c3fa9b2073eb24cc9aa8859 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:23:19 +0200 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Andy Butland --- .../Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs index cd3fda617917..006811a2c865 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs @@ -57,7 +57,7 @@ protected override void Migrate() (string TimeZoneName, Lazy TimeZoneOffset, Lazy WindowsTimeZoneName) timeZone; var configuredTimeZoneName = _migrationSettings.Value.LocalServerTimeZone; - if (!configuredTimeZoneName.IsNullOrWhiteSpace()) + if (configuredTimeZoneName.IsNullOrWhiteSpace() is false) { timeZone = ( configuredTimeZoneName, @@ -132,15 +132,15 @@ protected override void Migrate() private static string GetWindowsTimeZoneId(TimeZoneInfo timeZone) { - if (!timeZone.HasIanaId) + if (timeZone.HasIanaId is false) { return timeZone.Id; } - if (!TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZone.Id, out var windowsId)) + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZone.Id, out var windowsId) is false) { throw new InvalidOperationException( - $"Failed to convert local time zone IANA id '{timeZone.Id}' to a Windows id. Please manually configure the 'Umbraco:CMS:SystemDateMigration:LocalServerTimeZone' app setting with a valid Windows time zone name."); + $"Could not update system dates to UTC as it was not possible to convert the detected local time zone IANA id '{timeZone.Id}' to a Windows Id necessary for updates with SQL Server. Please manually configure the 'Umbraco:CMS:SystemDateMigration:LocalServerTimeZone' app setting with a valid Windows time zone name."); } return windowsId; From 62dac5e3d6a0b9696beec9a87bec690d71522695 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:31:04 +0200 Subject: [PATCH 3/3] Small rename --- .../Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs index 006811a2c865..c4b1f3da4753 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs @@ -173,20 +173,20 @@ private void MigrateDateColumnSQLite( // given these are historical dates in expected non-production environments, that are unlikely to be necessary to be 100% accurate, this is acceptable. var offsetInMinutes = -timezoneOffset.TotalMinutes; - var offSetInMinutesString = offsetInMinutes > 0 + var offsetInMinutesString = offsetInMinutes > 0 ? $"+{offsetInMinutes}" : $"{offsetInMinutes}"; - Sql sql = Sql($"UPDATE {tableName} SET {columName} = DATETIME({columName}, '{offSetInMinutesString} minutes')"); + Sql sql = Sql($"UPDATE {tableName} SET {columName} = DATETIME({columName}, '{offsetInMinutesString} minutes')"); scope.Database.Execute(sql); _logger.LogInformation( - "Migrated {TableName}.{ColumnName} from timezone {TimeZoneName} ({OffSetInMinutes}) to UTC.", + "Migrated {TableName}.{ColumnName} from timezone {TimeZoneName} ({OffsetInMinutes}) to UTC.", tableName, columName, timezoneName, - offSetInMinutesString); + offsetInMinutesString); } private void MigrateDateColumnSqlServer(IScope scope, string tableName, string columName, string timeZoneName)