From 1a164f7200e3263021a60fdf619dea88c5179f68 Mon Sep 17 00:00:00 2001 From: Andreas Lykke Borg <72602768+andreaslborg@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:07:20 +0100 Subject: [PATCH 1/4] Format migration expression values with the invariant culture --- .../Migrations/MigrationExpressionBase.cs | 5 +- .../MigrationExpressionBaseTests.cs | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs diff --git a/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs b/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs index 6b2208258023..0c9124c066c9 100644 --- a/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Runtime.ConstrainedExecution; using System.Text; using Microsoft.Extensions.Logging; @@ -204,11 +205,11 @@ protected string GetQuotedValue(object? val) case TypeCode.UInt16: case TypeCode.UInt32: case TypeCode.UInt64: - return val.ToString()!; + return Convert.ToString(val, CultureInfo.InvariantCulture)!; case TypeCode.DateTime: return SqlSyntax.GetQuotedValue(SqlSyntax.FormatDateTime((DateTime)val)); default: - return SqlSyntax.GetQuotedValue(val.ToString()!); + return SqlSyntax.GetQuotedValue(Convert.ToString(val, CultureInfo.InvariantCulture)!); } } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs new file mode 100644 index 000000000000..18b5b89afeaa --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Infrastructure.Migrations; +using Umbraco.Cms.Infrastructure.Migrations.Expressions.Insert.Expressions; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; +using Umbraco.Cms.Tests.Common.TestHelpers; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Migrations; + +[TestFixture] +public class MigrationExpressionBaseTests +{ + private CultureInfo _originalCulture = null!; + + [SetUp] + public void SetUp() => _originalCulture = CultureInfo.CurrentCulture; + + [TearDown] + public void TearDown() => CultureInfo.CurrentCulture = _originalCulture; + + [TestCase("fo-FO")] + [TestCase("sv-SE")] + [TestCase("nb-NO")] + [TestCase("fi-FI")] + [TestCase("da-DK")] + [TestCase("de-DE")] + [TestCase("en-US")] + public void Numeric_Values_Are_Formatted_Using_The_Invariant_Culture(string cultureName) + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + + var database = new TestDatabase(); + var context = new MigrationContext(new TestPlan(), database, Mock.Of>()); + var expression = new InsertDataExpression(context) { TableName = "umbracoLock" }; + expression.Rows.Add( + new InsertionDataDefinition + { + new("id", -73001), + new("ratio", 1.5m), + new("factor", -2.25d), + new("big", -9000000000L), + }); + + expression.Execute(); + + var sql = database.Operations.Single().Sql; + Assert.That(sql, Does.EndWith("VALUES (-73001,1.5,-2.25,-9000000000);")); + } + + private class TestPlan : MigrationPlan + { + public TestPlan() + : base("Test") + { + } + } +} From 20391b6d0cd7c6ac9b5831b9fcae0b06689bc5c8 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 13 Nov 2026 13:32:17 +0000 Subject: [PATCH 2/4] Format migration date/time values via the syntax provider and escape quoted strings. --- .../Services/SqliteSyntaxProvider.cs | 17 ++++ .../Migrations/MigrationExpressionBase.cs | 15 +++- .../SqlSyntax/ISqlSyntaxProvider.cs | 28 ++++++- .../SqlSyntax/SqlSyntaxProviderBase.cs | 23 +++++- .../SqlServerSyntaxProviderTests.cs | 46 ++++++++++- .../SqliteSyntaxProviderTests.cs | 79 +++++++++++++++++-- .../MigrationExpressionBaseTests.cs | 52 ++++++++++-- 7 files changed, 237 insertions(+), 23 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs index 858621217d3b..19d94964995e 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs +++ b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs @@ -1,5 +1,6 @@ using System.Data; using System.Data.Common; +using System.Globalization; using System.Linq.Expressions; using System.Reflection; using System.Runtime.InteropServices; @@ -104,6 +105,22 @@ public override string GetIndexType(IndexTypes indexTypes) /// public override string FormatGuid(Guid guid) => guid.ToString().ToUpperInvariant(); + /// + /// + /// SQLite stores date and time values as text, so this and the other date and time formats match those the ADO.NET provider writes for + /// parameters, keeping values comparable and sortable whichever way they were written. + /// + public override string FormatDateTimeOffset(DateTimeOffset dateTimeOffset) + => dateTimeOffset.ToString(@"yyyy\-MM\-dd HH\:mm\:ss.FFFFFFFzzz", CultureInfo.InvariantCulture); + + /// + public override string FormatDateOnly(DateOnly date) + => date.ToString(@"yyyy\-MM\-dd", CultureInfo.InvariantCulture); + + /// + public override string FormatTimeOnly(TimeOnly time) + => time.ToString(time.Ticks % TimeSpan.TicksPerSecond == 0 ? "HH:mm:ss" : "HH:mm:ss.fffffff", CultureInfo.InvariantCulture); + /// public override string Format(TableDefinition table) { diff --git a/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs b/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs index 0c9124c066c9..7140af6d1540 100644 --- a/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Runtime.ConstrainedExecution; using System.Text; using Microsoft.Extensions.Logging; using NPoco; @@ -182,10 +181,18 @@ protected string GetQuotedValue(object? val) return "NULL"; } - // Format Guids via the syntax provider to ensure consistent storage (e.g. Guids need to be uppercase for SQLite). - if (val is Guid guid) + // Format Guids and date/time types via the syntax provider to ensure consistent storage + // (e.g. Guids need to be uppercase for SQLite) and literals the database reads unambiguously. + switch (val) { - return SqlSyntax.GetQuotedValue(SqlSyntax.FormatGuid(guid)); + case Guid guid: + return SqlSyntax.GetQuotedValue(SqlSyntax.FormatGuid(guid)); + case DateTimeOffset dateTimeOffset: + return SqlSyntax.GetQuotedValue(SqlSyntax.FormatDateTimeOffset(dateTimeOffset)); + case DateOnly date: + return SqlSyntax.GetQuotedValue(SqlSyntax.FormatDateOnly(date)); + case TimeOnly time: + return SqlSyntax.GetQuotedValue(SqlSyntax.FormatTimeOnly(time)); } Type type = val.GetType(); diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs index c213d2a4f2d6..2757b25701b3 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs @@ -1,5 +1,6 @@ using System.Data; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq.Expressions; using System.Text.RegularExpressions; using NPoco; @@ -9,8 +10,6 @@ namespace Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; -// TODO (V18): Remove the default implementations in this interface. - /// /// Defines an SqlSyntaxProvider. /// @@ -347,6 +346,31 @@ DatabaseType GetUpdatedDatabaseType(DatabaseType current, string? connectionStri /// A string representation of the guid formatted for SQL. string FormatGuid(Guid guid) => guid.ToString(); + /// + /// Formats a value as a string suitable for use in SQL queries. + /// + /// The date, time and offset value to format. + /// A string representation of the value formatted for SQL, independent of the current culture. + // TODO (V20): Remove the default implementation. + string FormatDateTimeOffset(DateTimeOffset dateTimeOffset) + => dateTimeOffset.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz", CultureInfo.InvariantCulture); + + /// + /// Formats a value as a string suitable for use in SQL queries. + /// + /// The date value to format. + /// A string representation of the date formatted for SQL, independent of the current culture. + // TODO (V20): Remove the default implementation. + string FormatDateOnly(DateOnly date) => date.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + + /// + /// Formats a value as a string suitable for use in SQL queries. + /// + /// The time value to format. + /// A string representation of the time formatted for SQL, independent of the current culture. + // TODO (V20): Remove the default implementation. + string FormatTimeOnly(TimeOnly time) => time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture); + /// /// Formats a into its corresponding SQL statement. /// diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs index 30610d778953..b88daa74741d 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs @@ -11,7 +11,6 @@ using Umbraco.Cms.Core.Persistence; using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; -using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; @@ -311,11 +310,11 @@ public virtual string GetStringColumnWildcardComparison(string column, int param public virtual string GetQuotedName(string? name) => $"\"{name}\""; /// - /// Returns the specified string value enclosed in single quotes. + /// Returns the specified string value enclosed in single quotes, with any single quotes it contains escaped. /// /// The string value to quote. /// The quoted string value. - public virtual string GetQuotedValue(string value) => $"'{value}'"; + public virtual string GetQuotedValue(string value) => $"'{value.Replace("'", "''")}'"; /// public virtual string GetNullCastSuffix() => string.Empty; @@ -569,6 +568,24 @@ public virtual string FormatDateTime(DateTime date, bool includeTime = true) => /// public virtual string FormatGuid(Guid guid) => guid.ToString(); + /// + /// + /// ISO 8601 with a "T" separator is unambiguous for SQL Server date types, whatever the language + /// or DATEFORMAT setting of the connection. + /// + public virtual string FormatDateTimeOffset(DateTimeOffset dateTimeOffset) + => dateTimeOffset.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz", CultureInfo.InvariantCulture); + + /// + /// + /// YYYYMMDD is unambiguous for all SQL Server date types, whatever the language or DATEFORMAT + /// setting of the connection. + /// + public virtual string FormatDateOnly(DateOnly date) => date.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + + /// + public virtual string FormatTimeOnly(TimeOnly time) => time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture); + /// /// Formats a SQL create table statement for the specified table definition. /// diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.SqlServer/SqlServerSyntaxProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.SqlServer/SqlServerSyntaxProviderTests.cs index 5cb09cefea44..a090d5b58532 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.SqlServer/SqlServerSyntaxProviderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.SqlServer/SqlServerSyntaxProviderTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Globalization; using Microsoft.Extensions.Options; using NUnit.Framework; using Umbraco.Cms.Core.Configuration.Models; @@ -11,16 +12,53 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Persistence.SqlServer; [TestFixture] public class SqlServerSyntaxProviderTests { + private static readonly DateTime _dateTime = new(2026, 11, 3, 10, 30, 15, 250); + private static readonly DateTimeOffset _dateTimeOffset = new(2026, 11, 3, 10, 30, 15, 250, TimeSpan.FromHours(-1)); + private static readonly DateOnly _date = new(2026, 11, 3); + private static readonly TimeOnly _time = new(10, 30, 15, 250); + + private CultureInfo _originalCulture = null!; + + // fi-FI formats dates, times, decimals and negative signs differently from the invariant culture, + // so any formatting that depends on the current culture shows up in the results. + [SetUp] + public void SetUp() + { + _originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI"); + } + + [TearDown] + public void TearDown() => CultureInfo.CurrentCulture = _originalCulture; + [Test] public void Can_Format_Guid_Unchanged() { - var sut = new SqlServerSyntaxProvider(Options.Create(new GlobalSettings())); - - var result = sut.FormatGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); + var result = CreateSut().FormatGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); Assert.That(result, Is.EqualTo("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); } + [TestCase(true, ExpectedResult = "20261103 10:30:15")] + [TestCase(false, ExpectedResult = "20261103")] + public string Can_Format_DateTime(bool includeTime) => CreateSut().FormatDateTime(_dateTime, includeTime); + + [Test] + public void Can_Format_DateTimeOffset() + => Assert.That(CreateSut().FormatDateTimeOffset(_dateTimeOffset), Is.EqualTo("2026-11-03T10:30:15.2500000-01:00")); + + [Test] + public void Can_Format_DateOnly() + => Assert.That(CreateSut().FormatDateOnly(_date), Is.EqualTo("20261103")); + + [Test] + public void Can_Format_TimeOnly() + => Assert.That(CreateSut().FormatTimeOnly(_time), Is.EqualTo("10:30:15.2500000")); + + [Test] + public void Can_Quote_And_Escape_A_String_Value() + => Assert.That(CreateSut().GetQuotedValue("O'Brien's"), Is.EqualTo("'O''Brien''s'")); + [TestCase("", SqlServerSyntaxProvider.VersionName.Invalid)] [TestCase("13.0.7050.2", SqlServerSyntaxProvider.VersionName.V2016)] [TestCase("14.0.3485.1", SqlServerSyntaxProvider.VersionName.V2017)] @@ -40,4 +78,6 @@ public void Can_Map_Product_Version_To_Version_Name(string productVersion, SqlSe Assert.That(result.ProductVersionName, Is.EqualTo(expected)); } + + private static SqlServerSyntaxProvider CreateSut() => new(Options.Create(new GlobalSettings())); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.Sqlite/SqliteSyntaxProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.Sqlite/SqliteSyntaxProviderTests.cs index c91667e23bd3..43c4ca9febf7 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.Sqlite/SqliteSyntaxProviderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.Sqlite/SqliteSyntaxProviderTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Globalization; +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -13,15 +15,82 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Persistence.Sqlite; [TestFixture] public class SqliteSyntaxProviderTests { + private static readonly DateTime _dateTime = new(2026, 11, 3, 10, 30, 15, 250); + private static readonly DateTimeOffset _dateTimeOffset = new(2026, 11, 3, 10, 30, 15, 250, TimeSpan.FromHours(-1)); + private static readonly DateOnly _date = new(2026, 11, 3); + private static readonly TimeOnly _time = new(10, 30, 15, 250); + private static readonly TimeOnly _wholeSecondTime = new(10, 30, 15); + + private CultureInfo _originalCulture = null!; + + // fi-FI formats dates, times, decimals and negative signs differently from the invariant culture, + // so any formatting that depends on the current culture shows up in the results. + [SetUp] + public void SetUp() + { + _originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI"); + } + + [TearDown] + public void TearDown() => CultureInfo.CurrentCulture = _originalCulture; + [Test] public void Can_Format_Guid_Uppercase() { - var sut = new SqliteSyntaxProvider( - Options.Create(new GlobalSettings()), - Mock.Of>()); - - var result = sut.FormatGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); + var result = CreateSut().FormatGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); Assert.That(result, Is.EqualTo("A1B2C3D4-E5F6-7890-ABCD-EF1234567890")); } + + [TestCase(true, ExpectedResult = "20261103 10:30:15")] + [TestCase(false, ExpectedResult = "20261103")] + public string Can_Format_DateTime(bool includeTime) => CreateSut().FormatDateTime(_dateTime, includeTime); + + [Test] + public void Can_Format_DateTimeOffset() + => Assert.That(CreateSut().FormatDateTimeOffset(_dateTimeOffset), Is.EqualTo("2026-11-03 10:30:15.25-01:00")); + + [Test] + public void Can_Format_DateOnly() + => Assert.That(CreateSut().FormatDateOnly(_date), Is.EqualTo("2026-11-03")); + + [Test] + public void Can_Format_TimeOnly() + => Assert.That(CreateSut().FormatTimeOnly(_time), Is.EqualTo("10:30:15.2500000")); + + [Test] + public void Can_Format_TimeOnly_Without_Fractional_Seconds() + => Assert.That(CreateSut().FormatTimeOnly(_wholeSecondTime), Is.EqualTo("10:30:15")); + + [Test] + public void Can_Quote_And_Escape_A_String_Value() + => Assert.That(CreateSut().GetQuotedValue("O'Brien's"), Is.EqualTo("'O''Brien''s'")); + + [Test] + public void Date_And_Time_Values_Are_Formatted_As_Sqlite_Stores_Parameter_Values() + { + SqliteSyntaxProvider sut = CreateSut(); + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + Assert.Multiple(() => + { + Assert.That(sut.FormatDateTimeOffset(_dateTimeOffset), Is.EqualTo(GetStoredText(connection, _dateTimeOffset))); + Assert.That(sut.FormatDateOnly(_date), Is.EqualTo(GetStoredText(connection, _date))); + Assert.That(sut.FormatTimeOnly(_time), Is.EqualTo(GetStoredText(connection, _time))); + Assert.That(sut.FormatTimeOnly(_wholeSecondTime), Is.EqualTo(GetStoredText(connection, _wholeSecondTime))); + }); + } + + private static SqliteSyntaxProvider CreateSut() + => new(Options.Create(new GlobalSettings()), Mock.Of>()); + + private static string GetStoredText(SqliteConnection connection, object value) + { + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT @value"; + command.Parameters.AddWithValue("@value", value); + return (string)command.ExecuteScalar()!; + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs index 18b5b89afeaa..e296541cef9c 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs @@ -31,10 +31,7 @@ public void Numeric_Values_Are_Formatted_Using_The_Invariant_Culture(string cult { CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); - var database = new TestDatabase(); - var context = new MigrationContext(new TestPlan(), database, Mock.Of>()); - var expression = new InsertDataExpression(context) { TableName = "umbracoLock" }; - expression.Rows.Add( + var sql = ExecuteInsert( new InsertionDataDefinition { new("id", -73001), @@ -43,10 +40,53 @@ public void Numeric_Values_Are_Formatted_Using_The_Invariant_Culture(string cult new("big", -9000000000L), }); + Assert.That(sql, Does.EndWith("VALUES (-73001,1.5,-2.25,-9000000000);")); + } + + [TestCase("fo-FO")] + [TestCase("sv-SE")] + [TestCase("nb-NO")] + [TestCase("fi-FI")] + [TestCase("da-DK")] + [TestCase("de-DE")] + [TestCase("en-US")] + public void Date_And_Time_Values_Are_Formatted_By_The_Syntax_Provider_Independently_Of_The_Culture(string cultureName) + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + + var sql = ExecuteInsert( + new InsertionDataDefinition + { + new("date", new DateOnly(2026, 11, 3)), + new("time", new TimeOnly(10, 30, 15)), + new("moment", new DateTimeOffset(2026, 11, 3, 10, 30, 15, TimeSpan.FromHours(-1))), + }); + + Assert.That(sql, Does.EndWith("VALUES ('20261103','10:30:15.0000000','2026-11-03T10:30:15.0000000-01:00');")); + } + + [Test] + public void Single_Quotes_In_String_Values_Are_Escaped() + { + var sql = ExecuteInsert( + new InsertionDataDefinition + { + new("name", "O'Brien's 'quoted'"), + }); + + Assert.That(sql, Does.EndWith("VALUES ('O''Brien''s ''quoted''');")); + } + + private static string ExecuteInsert(InsertionDataDefinition row) + { + var database = new TestDatabase(); + var context = new MigrationContext(new TestPlan(), database, Mock.Of>()); + var expression = new InsertDataExpression(context) { TableName = "umbracoLock" }; + expression.Rows.Add(row); + expression.Execute(); - var sql = database.Operations.Single().Sql; - Assert.That(sql, Does.EndWith("VALUES (-73001,1.5,-2.25,-9000000000);")); + return database.Operations.Single().Sql; } private class TestPlan : MigrationPlan From 9e929759ea8ef03ba534ace79eb19f2542880d48 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 13 Nov 2026 13:43:37 +0000 Subject: [PATCH 3/4] Re-organise switch statements. --- .../Migrations/MigrationExpressionBase.cs | 4 ++-- .../Migrations/MigrationExpressionBaseTests.cs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs b/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs index 7140af6d1540..5f62ba3da391 100644 --- a/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs @@ -187,6 +187,8 @@ protected string GetQuotedValue(object? val) { case Guid guid: return SqlSyntax.GetQuotedValue(SqlSyntax.FormatGuid(guid)); + case DateTime dateTime: + return SqlSyntax.GetQuotedValue(SqlSyntax.FormatDateTime(dateTime)); case DateTimeOffset dateTimeOffset: return SqlSyntax.GetQuotedValue(SqlSyntax.FormatDateTimeOffset(dateTimeOffset)); case DateOnly date: @@ -213,8 +215,6 @@ protected string GetQuotedValue(object? val) case TypeCode.UInt32: case TypeCode.UInt64: return Convert.ToString(val, CultureInfo.InvariantCulture)!; - case TypeCode.DateTime: - return SqlSyntax.GetQuotedValue(SqlSyntax.FormatDateTime((DateTime)val)); default: return SqlSyntax.GetQuotedValue(Convert.ToString(val, CultureInfo.InvariantCulture)!); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs index e296541cef9c..e240ce01c25f 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs @@ -57,12 +57,13 @@ public void Date_And_Time_Values_Are_Formatted_By_The_Syntax_Provider_Independen var sql = ExecuteInsert( new InsertionDataDefinition { + new("created", new DateTime(2026, 11, 3, 10, 30, 15)), new("date", new DateOnly(2026, 11, 3)), new("time", new TimeOnly(10, 30, 15)), new("moment", new DateTimeOffset(2026, 11, 3, 10, 30, 15, TimeSpan.FromHours(-1))), }); - Assert.That(sql, Does.EndWith("VALUES ('20261103','10:30:15.0000000','2026-11-03T10:30:15.0000000-01:00');")); + Assert.That(sql, Does.EndWith("VALUES ('20261103 10:30:15','20261103','10:30:15.0000000','2026-11-03T10:30:15.0000000-01:00');")); } [Test] From 7e9a3a9bccb0662aa4416a1431c746e2999112b6 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 13 Nov 2026 13:45:24 +0000 Subject: [PATCH 4/4] refactor(infrastructure): group provider-formatted migration values and clarify SQLite remarks - Handle DateTime alongside Guid, DateTimeOffset, DateOnly and TimeOnly in MigrationExpressionBase.GetQuotedValue, so every type the syntax provider formats is in one place, and cover it in the migration date test. - Name the types in the SQLite date and time format remarks, as FormatDateTime is not overridden there. Co-Authored-By: Claude Opus 5.5 --- .../Services/SqliteSyntaxProvider.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs index ba8c8cc96092..e5bc6513ceaa 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs +++ b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs @@ -107,8 +107,9 @@ public override string GetIndexType(IndexTypes indexTypes) /// /// - /// SQLite stores date and time values as text, so this and the other date and time formats match those the ADO.NET provider writes for - /// parameters, keeping values comparable and sortable whichever way they were written. + /// SQLite stores date and time values as text, so the formats for , + /// and match those the ADO.NET provider writes for parameters, keeping values comparable and + /// sortable whichever way they were written. /// public override string FormatDateTimeOffset(DateTimeOffset dateTimeOffset) => dateTimeOffset.ToString(@"yyyy\-MM\-dd HH\:mm\:ss.FFFFFFFzzz", CultureInfo.InvariantCulture);