diff --git a/src/DocumentDbTests/Reading/query_by_sql.cs b/src/DocumentDbTests/Reading/query_by_sql.cs index c5ea5fe5b6..13f50b5e97 100644 --- a/src/DocumentDbTests/Reading/query_by_sql.cs +++ b/src/DocumentDbTests/Reading/query_by_sql.cs @@ -260,6 +260,36 @@ public async Task query_two_fields_by_one_named_parameter() user.ShouldNotBeNull(); } + [Fact] + public async Task query_with_null_named_parameter_GH_3045() + { + using var session = theStore.LightweightSession(); + session.Store(new User { FirstName = "Jeremy", LastName = "Miller" }); + session.Store(new User { FirstName = "Lindsey", LastName = "Miller" }); + session.Store(new User { FirstName = "Max", LastName = "Miller" }); + session.Store(new User { FirstName = "Frank", LastName = "Zombo" }); + await session.SaveChangesAsync(); + + // When :lastName is null, the "is null" check is true so all users are returned + var users = + (await session.QueryAsync( + "where (:lastName is null or data ->> 'LastName' = :lastName)", + new { lastName = (string?)null })) + .OrderBy(x => x.FirstName).ToArray(); + + users.Length.ShouldBe(4); + + // When :lastName has a value, only matching users are returned + var filtered = + (await session.QueryAsync( + "where (:lastName is null or data ->> 'LastName' = :lastName)", + new { lastName = (string?)"Miller" })) + .OrderBy(x => x.FirstName).ToArray(); + + filtered.Length.ShouldBe(3); + filtered[0].FirstName.ShouldBe("Jeremy"); + } + [Fact] public async Task query_for_multiple_documents() { diff --git a/src/Marten/Linq/QueryHandlers/AdvancedSqlQueryHandler.cs b/src/Marten/Linq/QueryHandlers/AdvancedSqlQueryHandler.cs index af3ec58a62..f0410f7eb3 100644 --- a/src/Marten/Linq/QueryHandlers/AdvancedSqlQueryHandler.cs +++ b/src/Marten/Linq/QueryHandlers/AdvancedSqlQueryHandler.cs @@ -132,8 +132,7 @@ public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) if (Parameters.Length == 1 && firstParameter != null && firstParameter.IsAnonymousType()) { - builder.Append(Sql); - builder.AddParameters(firstParameter); + NamedParameterHelper.AppendSqlWithNamedParameters(builder, Sql, firstParameter); } else { diff --git a/src/Marten/Linq/QueryHandlers/NamedParameterHelper.cs b/src/Marten/Linq/QueryHandlers/NamedParameterHelper.cs new file mode 100644 index 0000000000..6409482c3c --- /dev/null +++ b/src/Marten/Linq/QueryHandlers/NamedParameterHelper.cs @@ -0,0 +1,108 @@ +#nullable enable +using System; +using System.Collections.Generic; +using NpgsqlTypes; +using Weasel.Postgresql; + +namespace Marten.Linq.QueryHandlers; + +/// +/// Converts named-parameter SQL (using anonymous objects) to positional parameters +/// with proper NpgsqlDbType inference, so that null-valued parameters carry type +/// information to PostgreSQL. This fixes "42P08: could not determine data type of +/// parameter" errors when a named parameter's value is null. +/// +internal static class NamedParameterHelper +{ + /// + /// Appends the SQL to the builder, replacing :paramName references with positional + /// parameters that have proper NpgsqlDbType set based on the property's declared type. + /// This ensures null parameter values still carry type information. + /// + public static void AppendSqlWithNamedParameters(ICommandBuilder builder, string sql, object parameters) + { + var properties = parameters.GetType().GetProperties(); + var propLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in properties) + { + propLookup[property.Name] = (property.GetValue(parameters), property.PropertyType); + } + + // Walk through the SQL, find :paramName references, replace with positional parameters + var i = 0; + while (i < sql.Length) + { + var c = sql[i]; + + // Skip single-quoted strings + if (c == '\'') + { + var end = sql.IndexOf('\'', i + 1); + if (end == -1) end = sql.Length - 1; + builder.Append(sql.Substring(i, end - i + 1)); + i = end + 1; + continue; + } + + // Check for :: (PostgreSQL type cast) - skip it + if (c == ':' && i + 1 < sql.Length && sql[i + 1] == ':') + { + builder.Append("::"); + i += 2; + continue; + } + + // Check for :paramName + if (c == ':' && i + 1 < sql.Length && IsIdentifierStart(sql[i + 1])) + { + var nameStart = i + 1; + var nameEnd = nameStart; + while (nameEnd < sql.Length && IsIdentifierChar(sql[nameEnd])) + { + nameEnd++; + } + + var paramName = sql.Substring(nameStart, nameEnd - nameStart); + + if (propLookup.TryGetValue(paramName, out var entry)) + { + var (value, propertyType) = entry; + var underlyingType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + NpgsqlDbType? dbType = null; + if (value != null && PostgresqlProvider.Instance.HasTypeMapping(value.GetType())) + { + dbType = PostgresqlProvider.Instance.ToParameterType(value.GetType()); + } + else if (PostgresqlProvider.Instance.HasTypeMapping(underlyingType)) + { + dbType = PostgresqlProvider.Instance.ToParameterType(underlyingType); + } + + builder.AppendParameter((object?)value ?? DBNull.Value, dbType); + } + else + { + // Unknown parameter name - pass through as-is + builder.Append(sql.Substring(i, nameEnd - i)); + } + + i = nameEnd; + continue; + } + + builder.Append(c); + i++; + } + } + + private static bool IsIdentifierStart(char c) + { + return char.IsLetter(c) || c == '_'; + } + + private static bool IsIdentifierChar(char c) + { + return char.IsLetterOrDigit(c) || c == '_'; + } +} diff --git a/src/Marten/Linq/QueryHandlers/UserSuppliedQueryHandler.cs b/src/Marten/Linq/QueryHandlers/UserSuppliedQueryHandler.cs index 66aeb232dd..9b5b939480 100644 --- a/src/Marten/Linq/QueryHandlers/UserSuppliedQueryHandler.cs +++ b/src/Marten/Linq/QueryHandlers/UserSuppliedQueryHandler.cs @@ -60,8 +60,7 @@ public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) if (_parameters.Length == 1 && firstParameter != null && firstParameter.IsAnonymousType()) { - builder.Append(_sql); - builder.AddParameters(firstParameter); + NamedParameterHelper.AppendSqlWithNamedParameters(builder, _sql, firstParameter); } else {