Skip to content

CommandFactory does not use registered TypeHandler for parameter binding #173

@alagonterie

Description

@alagonterie

Repository: DapperLib/DapperAOT
Version: 1.0.48

Description

When a custom TypeHandler<T> is registered via [module: TypeHandler<T, THandler>], the generated CommandFactory does not use THandler.SetValue() for parameter binding. Instead, it generates p.Value = AsValue(typed.Property), which sets the raw custom type directly as the parameter value — causing SqlClient to throw:

System.ArgumentException: No mapping exists from object type MyCustomType to a known managed provider native type.

The TypeHandler<T> works correctly for result parsing (RowFactory uses Parse()), but is not used for parameter binding (CommandFactory uses AsValue() instead of SetValue()).

Reproduction

// Custom value type for typed SQL string parameters
public readonly record struct DbString(string? Value, bool IsAnsi, int Length, bool IsFixedLength);

// AOT TypeHandler
public sealed class DbStringHandler : Dapper.TypeHandler<DbString>
{
    public override void SetValue(DbParameter parameter, DbString value)
    {
        parameter.Value = value.Value ?? (object)DBNull.Value;
        parameter.DbType = value.IsAnsi ? DbType.AnsiString : DbType.String;
        if (value.Length > 0) parameter.Size = value.Length;
    }
    public override DbString Parse(DbParameter parameter) => new(parameter.Value as string);
}

// Registration
[module: DapperAot]
[module: SqlSyntax(SqlSyntax.SqlServer)]
[module: TypeHandler<DbString, DbStringHandler>]

// Usage — flat parameter overload (intercepted)
await connection.ExecuteAsync(
    "UPDATE Table SET Name = @Name WHERE Id = @Id",
    new { Name = new DbString("test", IsAnsi: true, Length: 25, IsFixedLength: false), Id = 1 }
);

Expected behavior

The generated CommandFactory.AddParameters() should call DbStringHandler.SetValue(parameter, value) for DbString properties, producing:

p.ParameterName = "Name";
TypeHandlerInstance.SetValue(p, typed.Name);  // sets DbType, Size, Value
ps.Add(p);

Actual behavior

The generated CommandFactory.AddParameters() produces:

p.ParameterName = "Name";
p.Direction = ParameterDirection.Input;
p.Value = AsValue(typed.Name);  // sets raw DbString struct as Value — SqlClient throws
ps.Add(p);

No DbType or Size is set. The TypeHandler.SetValue() is never called.

Generated code excerpt

private sealed class CommandFactory2 : global::Dapper.CommandFactory<object?>
// <anonymous type: int Id, DbString Name>
{
    public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args)
    {
        var typed = Cast(args, static () => new { Id = default(int), Name = default(DbString) });
        var ps = cmd.Parameters;
        DbParameter p;
        p = cmd.CreateParameter();
        p.ParameterName = "Id";
        p.DbType = DbType.Int32;
        p.Direction = ParameterDirection.Input;
        p.Value = AsValue(typed.Id);
        ps.Add(p);

        p = cmd.CreateParameter();
        p.ParameterName = "Name";
        p.Direction = ParameterDirection.Input;
        p.Value = AsValue(typed.Name);  // <-- should use TypeHandler.SetValue() here
        ps.Add(p);
    }
}

Workaround

Use CommandDefinition overloads for calls with custom TypeHandler parameters. CommandDefinition falls back to vanilla Dapper which respects SqlMapper.AddTypeHandler():

// Works — vanilla Dapper path uses TypeHandler
await connection.ExecuteAsync(new CommandDefinition(sql, new { Name = new DbString(...) }));

// Fails — AOT intercepted path ignores TypeHandler
await connection.ExecuteAsync(sql, new { Name = new DbString(...) });

Selectively disable AOT on affected methods:

[DapperAot(false)]
public async Task Handle(MyEvent @event) { ... }

Environment

  • Dapper.AOT 1.0.48
  • Dapper 2.1.72
  • .NET 10
  • Microsoft.Data.SqlClient 7.0.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions