Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions src/DocumentDbTests/Reading/query_by_sql.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>(
"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<User>(
"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()
{
Expand Down
3 changes: 1 addition & 2 deletions src/Marten/Linq/QueryHandlers/AdvancedSqlQueryHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
108 changes: 108 additions & 0 deletions src/Marten/Linq/QueryHandlers/NamedParameterHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#nullable enable
using System;
using System.Collections.Generic;
using NpgsqlTypes;
using Weasel.Postgresql;

namespace Marten.Linq.QueryHandlers;

/// <summary>
/// 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.
/// </summary>
internal static class NamedParameterHelper
{
/// <summary>
/// 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.
/// </summary>
public static void AppendSqlWithNamedParameters(ICommandBuilder builder, string sql, object parameters)
{
var properties = parameters.GetType().GetProperties();
var propLookup = new Dictionary<string, (object? value, Type propertyType)>(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 == '_';
}
}
3 changes: 1 addition & 2 deletions src/Marten/Linq/QueryHandlers/UserSuppliedQueryHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading