diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/PopulateSortableValueForDatePropertyData.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/PopulateSortableValueForDatePropertyData.cs
index 591bc346c94d..7e5baea7249d 100644
--- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/PopulateSortableValueForDatePropertyData.cs
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_3_0/PopulateSortableValueForDatePropertyData.cs
@@ -14,6 +14,10 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_3_0;
///
public class PopulateSortableValueForDatePropertyData : AsyncMigrationBase
{
+ // Updates are performed in batches to keep individual command durations bounded and to avoid
+ // holding locks across the entire table for the full duration of the migration.
+ private const int BatchSize = 10000;
+
// Property editor aliases that store date/time as JSON and implement IDataValueSortable.
private static readonly string[] _dateTimePropertyEditorAliases =
[
@@ -37,6 +41,7 @@ public PopulateSortableValueForDatePropertyData(
///
protected override Task MigrateAsync()
{
+ EnsureLongCommandTimeout(Database);
ExecuteMigration(Database, DatabaseType, _logger);
return Task.CompletedTask;
}
@@ -50,12 +55,43 @@ protected override Task MigrateAsync()
/// The number of rows affected.
public static int ExecuteMigration(IUmbracoDatabase database, DatabaseType databaseType, ILogger logger)
{
- // Build the IN clause for the property editor aliases.
- var aliasesInClause = string.Join(", ", _dateTimePropertyEditorAliases.Select(a => $"'{a}'"));
+ // Resolve the relevant property type ids up front so the UPDATE can filter with a static IN list.
+ // A subquery here causes SQL Server to pick a scan-with-row-goal plan for the batched UPDATE,
+ // evaluating TRY_CAST(JSON_VALUE(textValue, '$.date')) on every row of umbracoPropertyData before
+ // the semi-join prunes anything — poor performance on large tables even when no rows ultimately match.
+ // A static IN list lets the optimizer drive the query off the IX_umbracoPropertyData_PropertyTypeId
+ // index, so TRY_CAST / datetime() only evaluate on rows already narrowed down by property type.
+ List propertyTypeIds = GetSortablePropertyTypeIds(database);
+ if (propertyTypeIds.Count == 0)
+ {
+ logger.LogInformation(
+ "Skipping sortableValue population; no property types using date/time editors were found.");
+ return 0;
+ }
+
+ logger.LogInformation(
+ "Populating sortableValue for property data across {PropertyTypeCount} date/time property type(s).",
+ propertyTypeIds.Count);
+
+ var idsInClause = string.Join(", ", propertyTypeIds);
return databaseType == DatabaseType.SQLite
- ? MigrateSQLite(database, aliasesInClause, logger)
- : MigrateSqlServer(database, aliasesInClause, logger);
+ ? MigrateSQLite(database, idsInClause, logger)
+ : MigrateSqlServer(database, idsInClause, logger);
+ }
+
+ private static List GetSortablePropertyTypeIds(IUmbracoDatabase database)
+ {
+ var aliasesInClause = string.Join(", ", _dateTimePropertyEditorAliases.Select(a => $"'{a}'"));
+ var sql = $@"
+SELECT id
+FROM cmsPropertyType
+WHERE dataTypeId IN (
+ SELECT nodeId
+ FROM umbracoDataType
+ WHERE propertyEditorAlias IN ({aliasesInClause})
+)";
+ return database.Fetch(sql);
}
///
@@ -64,36 +100,26 @@ public static int ExecuteMigration(IUmbracoDatabase database, DatabaseType datab
///
/// The JSON format is: {"date":"2025-11-05T15:31:00+00:00","timeZone":"UTC"}
/// We extract the date, parse it as datetimeoffset, convert to UTC, and format as ISO 8601.
- /// The resulting format is: 2025-11-05T15:31:00.0000000+00:00
+ /// The resulting format is: 2025-11-05T15:31:00.0000000+00:00.
+ /// TRY_CAST is used so rows with unparseable dates are skipped rather than aborting the migration;
+ /// the same guard in the WHERE clause ensures the batch loop always makes progress.
+ /// The parsed datetimeoffset is computed once via CROSS APPLY and reused for both the filter
+ /// and the assignment, avoiding a second JSON_VALUE + TRY_CAST per row.
///
- private static int MigrateSqlServer(IUmbracoDatabase database, string aliasesInClause, ILogger logger)
+ private static int MigrateSqlServer(IUmbracoDatabase database, string propertyTypeIdsInClause, ILogger logger)
{
- // SQL Server: Use ISJSON to validate JSON before parsing, then use JSON_VALUE to extract the date,
- // CAST to datetimeoffset, SWITCHOFFSET to convert to UTC, and CONVERT with style 127 for ISO 8601 format.
- // Style 127 produces: yyyy-MM-ddTHH:mm:ss.nnnnnnn or yyyy-MM-ddTHH:mm:ss.nnnnnnn+00:00
var sql = $@"
-UPDATE umbracoPropertyData
-SET sortableValue = CONVERT(varchar(50), SWITCHOFFSET(CAST(JSON_VALUE(textValue, '$.date') AS datetimeoffset), '+00:00'), 127)
-WHERE propertyTypeId IN (
- SELECT id
- FROM cmsPropertyType
- WHERE dataTypeId IN (
- SELECT nodeId
- FROM umbracoDataType
- WHERE propertyEditorAlias IN ({aliasesInClause})
- )
-)
-AND textValue IS NOT NULL
-AND sortableValue IS NULL
-AND ISJSON(textValue) = 1
-AND JSON_VALUE(textValue, '$.date') IS NOT NULL";
-
- var rowsAffected = database.Execute(sql);
- logger.LogInformation(
- "Populated sortableValue for {RowCount} property data rows using SQL Server JSON functions.",
- rowsAffected);
+UPDATE TOP ({BatchSize}) pd
+SET sortableValue = CONVERT(varchar(50), SWITCHOFFSET(parsed.parsedDate, '+00:00'), 127)
+FROM umbracoPropertyData pd
+CROSS APPLY (SELECT TRY_CAST(JSON_VALUE(pd.textValue, '$.date') AS datetimeoffset) AS parsedDate) parsed
+WHERE pd.propertyTypeId IN ({propertyTypeIdsInClause})
+AND pd.textValue IS NOT NULL
+AND pd.sortableValue IS NULL
+AND ISJSON(pd.textValue) = 1
+AND parsed.parsedDate IS NOT NULL";
- return rowsAffected;
+ return ExecuteInBatches(database, sql, logger, "SQL Server");
}
///
@@ -103,36 +129,54 @@ AND ISJSON(textValue) = 1
/// SQLite has limited datetime manipulation capabilities, so we extract the date string
/// and use strftime to normalize it. For dates with timezone offsets, SQLite's datetime
/// function can parse ISO 8601 format and converts to UTC.
- /// The resulting format is: yyyy-MM-ddTHH:mm:ssZ
+ /// The resulting format is: yyyy-MM-ddTHH:mm:ssZ.
+ /// The datetime() IS NOT NULL guard in the WHERE clause excludes unparseable dates so the
+ /// batch loop always makes progress (writing NULL back would otherwise re-select the row).
///
- private static int MigrateSQLite(IUmbracoDatabase database, string aliasesInClause, ILogger logger)
+ private static int MigrateSQLite(IUmbracoDatabase database, string propertyTypeIdsInClause, ILogger logger)
{
- // SQLite: Use json_valid to validate JSON before parsing, then use json_extract to get the date value,
- // datetime() to parse and normalize to UTC. The datetime() function automatically handles timezone
- // offsets in ISO 8601 format and converts to UTC.
- // We then format using strftime to get a consistent sortable format.
+ // SQLite's default build does not support UPDATE ... LIMIT, so constrain the update via a
+ // subquery that selects a batch of matching row ids.
var sql = $@"
UPDATE umbracoPropertyData
SET sortableValue = strftime('%Y-%m-%dT%H:%M:%SZ', datetime(json_extract(textValue, '$.date')))
-WHERE propertyTypeId IN (
+WHERE id IN (
SELECT id
- FROM cmsPropertyType
- WHERE dataTypeId IN (
- SELECT nodeId
- FROM umbracoDataType
- WHERE propertyEditorAlias IN ({aliasesInClause})
- )
-)
-AND textValue IS NOT NULL
-AND sortableValue IS NULL
-AND json_valid(textValue) = 1
-AND json_extract(textValue, '$.date') IS NOT NULL";
-
- var rowsAffected = database.Execute(sql);
+ FROM umbracoPropertyData
+ WHERE propertyTypeId IN ({propertyTypeIdsInClause})
+ AND textValue IS NOT NULL
+ AND sortableValue IS NULL
+ AND json_valid(textValue) = 1
+ AND datetime(json_extract(textValue, '$.date')) IS NOT NULL
+ LIMIT {BatchSize}
+)";
+
+ return ExecuteInBatches(database, sql, logger, "SQLite");
+ }
+
+ private static int ExecuteInBatches(IUmbracoDatabase database, string sql, ILogger logger, string databaseProviderName)
+ {
+ var totalRowsAffected = 0;
+ while (true)
+ {
+ var rowsAffected = database.Execute(sql);
+ if (rowsAffected <= 0)
+ {
+ break;
+ }
+
+ totalRowsAffected += rowsAffected;
+ logger.LogInformation(
+ "Populated sortableValue for batch of {BatchRowCount} property data rows ({TotalRowCount} total so far).",
+ rowsAffected,
+ totalRowsAffected);
+ }
+
logger.LogInformation(
- "Populated sortableValue for {RowCount} property data rows using SQLite JSON functions.",
- rowsAffected);
+ "Populated sortableValue for {RowCount} property data rows using {DatabaseFlavour} JSON functions.",
+ totalRowsAffected,
+ databaseProviderName);
- return rowsAffected;
+ return totalRowsAffected;
}
}