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