Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
028b07f
quote table, column and alias names with SqlSyntaxProvider methods in…
idseefeld Jan 29, 2026
e406c32
refactoring private methods into new file as internal methods,
idseefeld Jan 29, 2026
8344bfb
refactor GetAlias method
idseefeld Jan 29, 2026
6511dea
Double check the change
idseefeld Jan 29, 2026
12072a1
improve code health
idseefeld Jan 30, 2026
95388db
change new static classes into public static partial class NPocoSqlEx…
idseefeld Jan 30, 2026
c090be3
Merge branch 'main' into v173/21451-quote-raw-sql-names-with-SqlSynta…
idseefeld Jan 30, 2026
4d610a6
Merge branch 'main' into v173/21451-quote-raw-sql-names-with-SqlSynta…
idseefeld Jan 30, 2026
b6dfd5b
Merge branch 'v173/21451-quote-raw-sql-names-with-SqlSyntaxProvider-m…
idseefeld Jan 30, 2026
a88479f
resolve some Copilot review suggestions
idseefeld Jan 30, 2026
f140a57
revert Copilot suggestion because it decreases code health
idseefeld Jan 30, 2026
c9deac7
revert test
idseefeld Jan 30, 2026
72c0ca7
revert refactoring for CodeScene
idseefeld Feb 2, 2026
100b472
delete obsolete Test
idseefeld Feb 2, 2026
a6608c8
remove new methods and updates, which are not relevat for this PR
idseefeld Feb 2, 2026
e549394
prepare for additional states in the future
idseefeld Feb 2, 2026
968a7a6
don't mix string building methods
idseefeld Feb 2, 2026
3eddfbd
fix SQL injection danger
idseefeld Feb 2, 2026
e44abf8
Merge branch 'main' into v173/21451-quote-raw-sql-names-with-SqlSynta…
idseefeld Feb 2, 2026
a4addf9
fix test for reverted methods
idseefeld Feb 2, 2026
6ece7b9
Merge branch 'main' into v173/21451-quote-raw-sql-names-with-SqlSynta…
idseefeld Feb 2, 2026
6ba7569
another SqlSyntax issue
idseefeld Feb 2, 2026
3a264f5
Add additional unit and integration tests verifying the refactorings …
AndyButland Feb 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 125 additions & 64 deletions src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ public static Sql<ISqlContext> WhereClosure<TDto>(this Sql<ISqlContext> sql, Exp
return sql.Where<TDto>(predicate, alias).Append(")");
}

// moved to NPocoSqlWhereExtensions.cs
// can be removed after code review of PR #21577 or when in main branch
//public static Sql<ISqlContext> WhereParam<TDto>(this Sql<ISqlContext> sql, Expression<Func<TDto, object?>> field, string param)
//{
// string s = $"{sql.GetColumns(columnExpressions: [field], withAlias: false).FirstOrDefault()} = {param}";
// return sql.Where(s, []);
//}

/// <summary>
/// Appends a WHERE clause to the Sql statement.
/// </summary>
Expand Down Expand Up @@ -87,7 +95,25 @@ public static Sql<ISqlContext> Where<TDto1, TDto2, TDto3>(this Sql<ISqlContext>
/// <returns>The Sql statement.</returns>
public static Sql<ISqlContext> WhereIn<TDto>(this Sql<ISqlContext> sql, Expression<Func<TDto, object?>> field, IEnumerable? values)
{
if (values == null)
{
return sql;
}

var fieldName = sql.SqlContext.SqlSyntax.GetFieldName(field);

string[] stringValues = [.. values.OfType<string>()]; // This is necessary to avoid failing attempting to convert to string[] when values contains non-string types
if (stringValues.Length > 0)
{
Attempt<string[]> attempt = values.TryConvertTo<string[]>();
if (attempt.Success)
{
values = attempt.Result?.Select(v => v?.ToLower());
sql.Where($"LOWER({fieldName}) IN (@values)", new { values });
return sql;
}
}

sql.Where($"{fieldName} IN (@values)", new { values });
return sql;
}
Expand Down Expand Up @@ -788,6 +814,28 @@ public static Sql<ISqlContext> SelectMax<TDto>(this Sql<ISqlContext> sql, Expres
return sql.Select($"COALESCE(MAX ({sql.SqlContext.SqlSyntax.GetFieldName(field)}), {coalesceValue})");
}

/// <summary>
/// Adds a SQL SELECT statement to retrieve the maximum value of the specified field from the table associated
/// with the specified DTO type.
/// </summary>
/// <typeparam name="TDto">The type of the Data Transfer Object (DTO) that represents the table from which the maximum value will be
/// selected.</typeparam>
/// <param name="sql">The SQL query builder to which the SELECT statement will be appended. Cannot be <see langword="null"/>.</param>
/// <param name="field">An expression specifying the field for which the maximum value will be calculated. Cannot be <see
/// langword="null"/>.</param>
/// <param name="coalesceValue">COALESCE string value.</param>
/// <returns>A modified SQL query builder that includes the SELECT statement for the maximum value of the specified
/// field or the coalesceValue.</returns>
// moved to NPocoSqlSelectExtensions.cs
// can be removed after code review of PR #21577 or when in main branch
//public static Sql<ISqlContext> SelectMax<TDto>(this Sql<ISqlContext> sql, Expression<Func<TDto, object?>> field, string coalesceValue)
//{
// ArgumentNullException.ThrowIfNull(sql);
// ArgumentNullException.ThrowIfNull(field);

// return sql.Select($"COALESCE(MAX {sql.SqlContext.SqlSyntax.GetFieldName(field)}), '{coalesceValue}')");
//}

/// <summary>
/// Adds a SQL SELECT statement to retrieve the sum of the values of the specified field from the table associated
/// with the specified DTO type.
Expand Down Expand Up @@ -1483,20 +1531,22 @@ public static Sql<ISqlContext> AppendForUpdateHint(this Sql<ISqlContext> sql)

#region Aliasing

internal static string GetAliasedField(this Sql<ISqlContext> sql, string field)
{
// get alias, if aliased
//
// regex looks for pattern "([\w+].[\w+]) AS ([\w+])" ie "(field) AS (alias)"
// and, if found & a group's field matches the field name, returns the alias
//
// so... if query contains "[umbracoNode].[nodeId] AS [umbracoNode__nodeId]"
// then GetAliased for "[umbracoNode].[nodeId]" returns "[umbracoNode__nodeId]"

MatchCollection matches = sql.SqlContext.SqlSyntax.AliasRegex.Matches(sql.SQL);
Match? match = matches.Cast<Match>().FirstOrDefault(m => m.Groups[1].Value.InvariantEquals(field));
return match == null ? field : match.Groups[2].Value;
}
// moved to NPocoSqlExtensionsInternal.cs
// can be removed after code review of PR #21577 or when in main branch
//internal static string GetAliasedField(this Sql<ISqlContext> sql, string field)
//{
// // get alias, if aliased
// //
// // regex looks for pattern "([\w+].[\w+]) AS ([\w+])" ie "(field) AS (alias)"
// // and, if found & a group's field matches the field name, returns the alias
// //
// // so... if query contains "[umbracoNode].[nodeId] AS [umbracoNode__nodeId]"
// // then GetAliased for "[umbracoNode].[nodeId]" returns "[umbracoNode__nodeId]"

// MatchCollection matches = sql.SqlContext.SqlSyntax.AliasRegex.Matches(sql.SQL);
// Match? match = matches.Cast<Match>().FirstOrDefault(m => m.Groups[1].Value.InvariantEquals(field));
// return match == null ? field : match.Groups[2].Value;
//}

#endregion

Expand All @@ -1505,55 +1555,64 @@ internal static string GetAliasedField(this Sql<ISqlContext> sql, string field)
public static Sql<ISqlContext> AppendSubQuery(this Sql<ISqlContext> sql, Sql<ISqlContext> subQuery, string alias)
{
// Append the subquery as a derived table with an alias
sql.Append("(").Append(subQuery.SQL, subQuery.Arguments).Append($") AS {alias}");
sql.Append("(").Append(subQuery.SQL, subQuery.Arguments).Append($") AS {sql.SqlContext.SqlSyntax.GetQuotedName(alias)}");

return sql;
}

private static string[] GetColumns<TDto>(this Sql<ISqlContext> sql, string? tableAlias = null, string? referenceName = null, Expression<Func<TDto, object?>>[]? columnExpressions = null, bool withAlias = true, bool forInsert = false)
{
PocoData? pd = sql.SqlContext.PocoDataFactory.ForType(typeof(TDto));
var tableName = tableAlias ?? pd.TableInfo.TableName;
var queryColumns = pd.QueryColumns.ToList();

Dictionary<string, string>? aliases = null;

if (columnExpressions != null && columnExpressions.Length > 0)
{
var names = columnExpressions.Select(x =>
{
(MemberInfo member, var alias) = ExpressionHelper.FindProperty(x);
var field = member as PropertyInfo;
var fieldName = field?.GetColumnName();
if (alias != null && fieldName is not null)
{
aliases ??= new Dictionary<string, string>();
aliases[fieldName] = alias;
}
return fieldName;
}).ToArray();

//only get the columns that exist in the selected names
queryColumns = queryColumns.Where(x => names.Contains(x.Key)).ToList();

//ensure the order of the columns in the expressions is the order in the result
queryColumns.Sort((a, b) => names.IndexOf(a.Key).CompareTo(names.IndexOf(b.Key)));
}

string? GetAlias(PocoColumn column)
{
if (aliases != null && aliases.TryGetValue(column.ColumnName, out var alias))
{
return alias;
}

return withAlias ? (string.IsNullOrEmpty(column.ColumnAlias) ? column.MemberInfoKey : column.ColumnAlias) : null;
}

return queryColumns
.Select(x => sql.SqlContext.SqlSyntax.GetColumn(sql.SqlContext.DatabaseType, tableName, x.Value.ColumnName, GetAlias(x.Value)!, referenceName, forInsert: forInsert))
.ToArray();
}
// moved to NPocoSqlExtensionsInternal.cs
// can be removed after code review of PR #21577 or when in main branch
//private static string[] GetColumns<TDto>(this Sql<ISqlContext> sql, string? tableAlias = null, string? referenceName = null, Expression<Func<TDto, object?>>[]? columnExpressions = null, bool withAlias = true, bool forInsert = false)
//{
// PocoData? pd = sql.SqlContext.PocoDataFactory.ForType(typeof(TDto));
// var tableName = tableAlias ?? pd.TableInfo.TableName;
// var queryColumns = pd.QueryColumns.ToList();

// Dictionary<string, string>? aliases = null;

// if (columnExpressions != null && columnExpressions.Length > 0)
// {
// var names = columnExpressions.Select(x =>
// {
// (MemberInfo member, var alias) = ExpressionHelper.FindProperty(x);
// var field = member as PropertyInfo;
// var fieldName = field?.GetColumnName();
// if (alias != null && fieldName is not null)
// {
// aliases ??= new Dictionary<string, string>();
// aliases[fieldName] = alias;
// }
// return fieldName;
// }).ToArray();

// //only get the columns that exist in the selected names
// queryColumns = queryColumns.Where(x => names.Contains(x.Key)).ToList();

// //ensure the order of the columns in the expressions is the order in the result
// queryColumns.Sort((a, b) => names.IndexOf(a.Key).CompareTo(names.IndexOf(b.Key)));
// }

// string? GetAlias(PocoColumn column)
// {
// if (aliases != null && aliases.TryGetValue(column.ColumnName, out var alias))
// {
// return alias;
// }

// if ((column.MemberInfoKey.InvariantEquals("uniqueid") && !column.MemberInfoKey.Equals("uniqueId"))
// || (column.MemberInfoKey.InvariantEquals("languageid") && !column.MemberInfoKey.Equals("languageId")))
// {
// return withAlias ? (string.IsNullOrEmpty(column.ColumnAlias) ? column.ColumnName
// : column.ColumnAlias) : null;
// }

// return withAlias ? (string.IsNullOrEmpty(column.ColumnAlias) ? column.MemberInfoKey : column.ColumnAlias) : null;
// }

// return queryColumns
// .Select(x => sql.SqlContext.SqlSyntax.GetColumn(sql.SqlContext.DatabaseType, tableName, x.Value.ColumnName, GetAlias(x.Value)!, referenceName, forInsert: forInsert))
// .ToArray();
//}

public static string GetTableName(this Type type)
{
Expand All @@ -1564,11 +1623,13 @@ public static string GetTableName(this Type type)
return string.IsNullOrWhiteSpace(attr?.Value) ? string.Empty : attr.Value;
}

private static string GetColumnName(this PropertyInfo column)
{
ColumnAttribute? attr = column.FirstAttribute<ColumnAttribute>();
return string.IsNullOrWhiteSpace(attr?.Name) ? column.Name : attr.Name;
}
// moved to SqlSyntaxExtensions.cs
// can be removed after code review of PR #21577 or when in main branch
//private static string GetColumnName(this PropertyInfo column)
//{
// ColumnAttribute? attr = column.FirstAttribute<ColumnAttribute>();
// return string.IsNullOrWhiteSpace(attr?.Name) ? column.Name : attr.Name;
//}

public static string ToText(this Sql sql)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Text.RegularExpressions;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence;

namespace Umbraco.Extensions
{
public static partial class NPocoSqlExtensions
{
internal static string GetAliasedField(this Sql<ISqlContext> sql, string field)
{
// get alias, if aliased
//
// regex looks for pattern "([\w+].[\w+]) AS ([\w+])" ie "(field) AS (alias)"
// and, if found & a group's field matches the field name, returns the alias
//
// so... if query contains "[umbracoNode].[nodeId] AS [umbracoNode__nodeId]"
// then GetAliased for "[umbracoNode].[nodeId]" returns "[umbracoNode__nodeId]"

MatchCollection matches = sql.SqlContext.SqlSyntax.AliasRegex.Matches(sql.SQL);
Match? match = matches.Cast<Match>().FirstOrDefault(m => m.Groups[1].Value.InvariantEquals(field));
return match == null ? field : match.Groups[2].Value;
}

internal static string[] GetColumns<TDto>(this Sql<ISqlContext> sql, string? tableAlias = null, string? referenceName = null, Expression<Func<TDto, object?>>[]? columnExpressions = null, bool withAlias = true, bool forInsert = false)
{
PocoData? pd = sql.SqlContext.PocoDataFactory.ForType(typeof(TDto));
var tableName = tableAlias ?? pd.TableInfo.TableName;
var queryColumns = pd.QueryColumns.ToList();

Dictionary<string, string>? aliases = null;

if (columnExpressions != null && columnExpressions.Length > 0)
{
var names = columnExpressions.Select(x =>
{
(MemberInfo member, var alias) = ExpressionHelper.FindProperty(x);
var field = member as PropertyInfo;
var fieldName = field?.GetColumnName();
if (alias != null && fieldName is not null)
{
aliases ??= new Dictionary<string, string>();
aliases[fieldName] = alias;
}
return fieldName;
}).ToArray();

//only get the columns that exist in the selected names
queryColumns = queryColumns.Where(x => names.Contains(x.Key)).ToList();

//ensure the order of the columns in the expressions is the order in the result
queryColumns.Sort((a, b) => names.IndexOf(a.Key).CompareTo(names.IndexOf(b.Key)));
}

string? GetAlias(PocoColumn column)
{
if (aliases != null && aliases.TryGetValue(column.ColumnName, out var alias))
{
return alias;
}

return withAlias ? (string.IsNullOrEmpty(column.ColumnAlias) ? column.MemberInfoKey : column.ColumnAlias) : null;
}

return queryColumns
.Select(x => sql.SqlContext.SqlSyntax.GetColumn(
sql.SqlContext.DatabaseType,
tableName,
x.Value.ColumnName,
GetAlias(x.Value),
referenceName,
forInsert: forInsert))
.ToArray();
}
}
}
37 changes: 37 additions & 0 deletions src/Umbraco.Infrastructure/Persistence/NPocoSqlSelectExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Collections;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Querying;
using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax;

namespace Umbraco.Extensions
{
public static partial class NPocoSqlExtensions
{
/// <summary>
/// Adds a SQL SELECT statement to retrieve the maximum value of the specified field from the table associated
/// with the specified DTO type.
/// </summary>
/// <typeparam name="TDto">The type of the Data Transfer Object (DTO) that represents the table from which the maximum value will be
/// selected.</typeparam>
/// <param name="sql">The SQL query builder to which the SELECT statement will be appended. Cannot be <see langword="null"/>.</param>
/// <param name="field">An expression specifying the field for which the maximum value will be calculated. Cannot be <see
/// langword="null"/>.</param>
/// <param name="coalesceValue">COALESCE string value.</param>
/// <returns>A modified SQL query builder that includes the SELECT statement for the maximum value of the specified
/// field or the coalesceValue.</returns>
public static Sql<ISqlContext> SelectMax<TDto>(this Sql<ISqlContext> sql, Expression<Func<TDto, object?>> field, string coalesceValue)
{
ArgumentNullException.ThrowIfNull(sql);
ArgumentNullException.ThrowIfNull(field);

return sql.Select($"COALESCE(MAX {sql.SqlContext.SqlSyntax.GetFieldName(field)}), '{coalesceValue}')");
}

}
}
22 changes: 22 additions & 0 deletions src/Umbraco.Infrastructure/Persistence/NPocoSqlWhereExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Collections;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Querying;
using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax;

namespace Umbraco.Extensions
{
public static partial class NPocoSqlExtensions
{
public static Sql<ISqlContext> WhereParam<TDto>(this Sql<ISqlContext> sql, Expression<Func<TDto, object?>> field, string param)
{
string s = $"{sql.GetColumns(columnExpressions: [field], withAlias: false).FirstOrDefault()} = {param}";
return sql.Where(s, []);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public AuditRepository(
public IEnumerable<IAuditItem> Get(AuditType type, IQuery<IAuditItem> query)
{
Sql<ISqlContext>? sqlClause = GetBaseQuery(false)
.Where("(logHeader=@0)", type.ToString());
.Where($"({SqlSyntax.GetQuotedColumnName("logHeader")}=@0)", type.ToString());

var translator = new SqlTranslator<IAuditItem>(sqlClause, query);
Sql<ISqlContext> sql = translator.Translate();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,10 +545,10 @@ private string ApplyCustomOrdering(ref Sql<ISqlContext> sql, Ordering ordering)
{
// sorting by a custom field, so set-up sub-query for ORDER BY clause to pull through value
// from 'current' content version for the given order by field
var sortedInt = string.Format(SqlContext.SqlSyntax.ConvertIntegerToOrderableString, "intValue");
var sortedDecimal = string.Format(SqlContext.SqlSyntax.ConvertDecimalToOrderableString, "decimalValue");
var sortedDate = string.Format(SqlContext.SqlSyntax.ConvertDateToOrderableString, "dateValue");
var sortedString = "COALESCE(varcharValue,'')"; // assuming COALESCE is ok for all syntaxes
var sortedInt = string.Format(SqlContext.SqlSyntax.ConvertIntegerToOrderableString, QuoteColumnName("intValue"));
var sortedDecimal = string.Format(SqlContext.SqlSyntax.ConvertDecimalToOrderableString, QuoteColumnName("decimalValue"));
var sortedDate = string.Format(SqlContext.SqlSyntax.ConvertDateToOrderableString, QuoteColumnName("dateValue"));
var sortedString = $"COALESCE({QuoteColumnName("varcharValue")},'')"; // assuming COALESCE is ok for all syntaxes

// needs to be an outer join since there's no guarantee that any of the nodes have values for this property
Sql<ISqlContext> innerSql = Sql().Select($@"CASE
Expand Down
Loading
Loading