diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs index 6540f794ee09..e5bc6513ceaa 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,23 @@ public override string GetIndexType(IndexTypes indexTypes) /// public override string FormatGuid(Guid guid) => guid.ToString().ToUpperInvariant(); + /// + /// + /// 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); + + /// + 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 6b2208258023..5f62ba3da391 100644 --- a/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/MigrationExpressionBase.cs @@ -1,4 +1,4 @@ -using System.Runtime.ConstrainedExecution; +using System.Globalization; using System.Text; using Microsoft.Extensions.Logging; using NPoco; @@ -181,10 +181,20 @@ 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 DateTime dateTime: + return SqlSyntax.GetQuotedValue(SqlSyntax.FormatDateTime(dateTime)); + 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(); @@ -204,11 +214,9 @@ protected string GetQuotedValue(object? val) case TypeCode.UInt16: case TypeCode.UInt32: case TypeCode.UInt64: - return val.ToString()!; - case TypeCode.DateTime: - return SqlSyntax.GetQuotedValue(SqlSyntax.FormatDateTime((DateTime)val)); + return Convert.ToString(val, CultureInfo.InvariantCulture)!; default: - return SqlSyntax.GetQuotedValue(val.ToString()!); + return SqlSyntax.GetQuotedValue(Convert.ToString(val, CultureInfo.InvariantCulture)!); } } } 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 b151f1296aca..b477b65e7216 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 586cf79fbf5a..9c3d37ca6491 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.Sqlite/SqliteSyntaxProviderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Persistence.Sqlite/SqliteSyntaxProviderTests.cs @@ -15,18 +15,74 @@ 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))); + }); + } + [Test] public void Can_Order_Integers_Numerically_By_Orderable_String() { @@ -62,6 +118,14 @@ public void Can_Order_Decimals_Numerically_By_Orderable_String() 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()!; + } + private static IEnumerable OrderByExpression(string expression, string columnType, IEnumerable values, Func read) { using var connection = new SqliteConnection("Data Source=:memory:"); 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..e240ce01c25f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationExpressionBaseTests.cs @@ -0,0 +1,100 @@ +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 sql = ExecuteInsert( + new InsertionDataDefinition + { + new("id", -73001), + new("ratio", 1.5m), + new("factor", -2.25d), + 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("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','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(); + + return database.Operations.Single().Sql; + } + + private class TestPlan : MigrationPlan + { + public TestPlan() + : base("Test") + { + } + } +}