From f7721d1d0d47b845a2024f36f7092c5977471fd2 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 18 Aug 2026 19:49:46 +0100 Subject: [PATCH 1/2] Support ICustomQueryParameter members: the value adds itself A member implementing SqlMapper.ICustomQueryParameter (a TVP from AsTableValuedParameter being the common case, on a DataTable or an IEnumerable) previously bound as a single raw parameter; now the generated AddParameters calls value.AddParameter(command, name), which is the whole vanilla contract. A null reference-typed member throws with vanilla's exact message; a struct member gets no null test. The command cannot be prepared (no declared DbType). The list-expansion guards generalise to cover this: expandable and custom members are both 'self-binding' - they contribute an unknowable number of parameters - so the same parse-side rules apply (no command caching; skip multi-exec; skip alongside output/return parameters, which PostProcess reads back by index). The shared p local pre-scan learns the new member kind too. New CustomParameters fixture covers interface-typed, class and struct members, a custom member alongside a plain one, and both skips. --- .../DapperInterceptorGenerator.cs | 41 +++-- .../CodeAnalysis/Model/ParamPlan.cs | 12 +- .../Internal/Inspection.cs | 29 ++++ .../Interceptors/CustomParameters.input.cs | 43 +++++ .../Interceptors/CustomParameters.output.cs | 162 ++++++++++++++++++ .../CustomParameters.output.netfx.cs | 162 ++++++++++++++++++ .../CustomParameters.output.netfx.txt | 4 + .../Interceptors/CustomParameters.output.txt | 4 + 8 files changed, 444 insertions(+), 13 deletions(-) create mode 100644 test/Dapper.AOT.Test/Interceptors/CustomParameters.input.cs create mode 100644 test/Dapper.AOT.Test/Interceptors/CustomParameters.output.cs create mode 100644 test/Dapper.AOT.Test/Interceptors/CustomParameters.output.netfx.cs create mode 100644 test/Dapper.AOT.Test/Interceptors/CustomParameters.output.netfx.txt create mode 100644 test/Dapper.AOT.Test/Interceptors/CustomParameters.output.txt diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs index d1eb0516..ec0f0459 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs @@ -102,13 +102,15 @@ private static string InterceptorFilePath(in ParseState ctx, Location location) return ctx.SemanticModel.Compilation.Options.SourceReferenceResolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath; } - private static bool HasExpandableMember(ParamPlan? plan) + // expandable (in @ids) and custom (ICustomQueryParameter) members bind themselves, + // contributing an unknowable number of parameters; the guards below treat them alike + private static bool HasSelfBindingMember(ParamPlan? plan) { if (plan is not null) { foreach (var member in plan.Members) { - if (member.IsMapped && member.IsExpandable) return true; + if (member.IsMapped && (member.IsExpandable || member.IsCustom)) return true; } } return false; @@ -178,13 +180,13 @@ private static InterceptedMethod ProjectMethod(IMethodSymbol method) var parameterMap = BuildParameterMap(ctx, op, sql, ref flags, map, location, out var parseFlags); var parameterPlan = ParamPlan.Create(argExpression?.Type); - if (parameterPlan is { IsCollection: true, Element: { } element } && HasExpandableMember(element)) + if (parameterPlan is { IsCollection: true, Element: { } element } && HasSelfBindingMember(element)) { - // multi-exec batch reuse updates parameters in-place, which cannot re-expand a - // list whose size changed between items; leave such call-sites on vanilla Dapper + // multi-exec batch reuse updates parameters in-place, which cannot re-bind a + // self-binding member; leave such call-sites on vanilla Dapper return new SkippedSourceState(new LocationSnapshot(location), flags); } - if (HasExpandableMember(parameterPlan) && HasNonInputMember(parameterPlan)) + if (HasSelfBindingMember(parameterPlan) && HasNonInputMember(parameterPlan)) { // PostProcess addresses output/return parameters by *index*, and an expanded // list contributes a runtime-variable number of parameters before them; leave @@ -199,9 +201,9 @@ private static InterceptedMethod ProjectMethod(IMethodSymbol method) { canBeCached = false; } - else if (HasExpandableMember(parameterPlan)) + else if (HasSelfBindingMember(parameterPlan)) { - canBeCached = false; // list-expansion changes the parameter shape per call + canBeCached = false; // self-binding members change the parameter shape per call } if (!canBeCached) flags &= ~OperationFlags.CacheCommand; @@ -1200,7 +1202,8 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co { foreach (var member in planMembers) { - if (member.IsMapped && !member.IsCancellation && !member.IsRowCount && !member.IsExpandable + if (member.IsMapped && !member.IsCancellation && !member.IsRowCount + && !member.IsExpandable && !member.IsCustom && SqlTools.IncludeParameter(map, member.CodeName, out _)) { needsParameterLocal = true; @@ -1289,6 +1292,22 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co switch (mode) { case WriteArgsMode.Add: + if (member.IsCustom) + { + // ICustomQueryParameter (TVPs etc): the value adds itself; it declares + // no DbType, so the command cannot be prepared. The null throw matches + // vanilla Dapper's, which has no way to name a parameter it never got. + flags &= ~WriteArgsFlags.CanPrepare; + if (!member.IsValueType) + { + sb.Append("if (").Append(source).Append(".").Append(member.CodeName) + .Append(" is null) throw new global::System.InvalidOperationException(\"Member '") + .Append(member.CodeName).Append("' is an ICustomQueryParameter and cannot be null\");").NewLine(); + } + sb.Append(source).Append(".").Append(member.CodeName).Append(".AddParameter(cmd.Command!, ") + .AppendVerbatimLiteral(member.DbName).Append(");").NewLine(); + break; + } if (member.IsExpandable) { // list-expansion (where X in @ids): delegate to Dapper's own implementation, @@ -1369,10 +1388,10 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co } break; case WriteArgsMode.Update: - if (member.IsExpandable) + if (member.IsExpandable || member.IsCustom) { // update is only reachable via command-cache reuse and batch, both of - // which are refused for expandable members at parse + // which are refused for self-binding members at parse break; } if (member.IsDbString) diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs index 4e278b76..ce3f21a1 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs @@ -145,6 +145,8 @@ public override int GetHashCode() public ParameterDirection Direction { get; } public bool IsDbString { get; } public bool IsExpandable { get; } // enumerable member: list-expansion (in @ids) applies + public bool IsCustom { get; } // SqlMapper.ICustomQueryParameter: the value binds itself + public bool IsValueType { get; } // of the member's own type; decides the null test public bool HasDbType { get; } // no DbType => cannot Prepare public string? DbTypeName { get; } // for "p.DbType = global::System.Data.DbType.X;" public int? EffectiveSize { get; } // after the [n]varchar(max) adjustment @@ -154,7 +156,8 @@ public override int GetHashCode() public string TypeName { get; } // emitted (Append) form, for Parse in post-process private ParamMember(bool isMapped, bool isCancellation, bool isRowCount, string codeName, string dbName, - ParameterDirection direction, bool isDbString, bool isExpandable, bool hasDbType, string? dbTypeName, int? effectiveSize, + ParameterDirection direction, bool isDbString, bool isExpandable, bool isCustom, bool isValueType, + bool hasDbType, string? dbTypeName, int? effectiveSize, bool useSetValueWithDefaultSize, byte? precision, byte? scale, string typeName) { IsMapped = isMapped; @@ -165,6 +168,8 @@ private ParamMember(bool isMapped, bool isCancellation, bool isRowCount, string Direction = direction; IsDbString = isDbString; IsExpandable = isExpandable; + IsCustom = isCustom; + IsValueType = isValueType; HasDbType = hasDbType; DbTypeName = dbTypeName; EffectiveSize = effectiveSize; @@ -178,7 +183,7 @@ public static ParamMember Create(in ElementMember member) { if (!member.IsMapped) { - return new(false, false, false, "", "", default, false, false, false, null, null, false, null, null, ""); + return new(false, false, false, "", "", default, false, false, false, false, false, null, null, false, null, null, ""); } var dbType = member.GetDbType(out _); var size = member.TryGetValue("Size"); @@ -203,6 +208,7 @@ public static ParamMember Create(in ElementMember member) } return new(true, member.IsCancellation, member.IsRowCount, member.CodeName, member.DbName, member.Direction, member.DapperSpecialType is DapperSpecialType.DbString, member.IsExpandable, + member.DapperSpecialType is DapperSpecialType.CustomQueryParameter, member.CodeType!.IsValueType, dbType is not null, dbType?.ToString(), size, useSetValueWithDefaultSize, member.TryGetValue("Precision"), member.TryGetValue("Scale"), CodeWriter.GetAppendTypeName(member.CodeType!)); @@ -216,6 +222,8 @@ public bool Equals(ParamMember other) => IsMapped == other.IsMapped && Direction == other.Direction && IsDbString == other.IsDbString && IsExpandable == other.IsExpandable + && IsCustom == other.IsCustom + && IsValueType == other.IsValueType && HasDbType == other.HasDbType && string.Equals(DbTypeName, other.DbTypeName, StringComparison.Ordinal) && EffectiveSize == other.EffectiveSize diff --git a/src/Dapper.AOT.Analyzers/Internal/Inspection.cs b/src/Dapper.AOT.Analyzers/Internal/Inspection.cs index b4cb2679..70b613e2 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Inspection.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Inspection.cs @@ -535,6 +535,33 @@ internal enum DapperSpecialType { None = 0, DbString = 1 << 0, + CustomQueryParameter = 1 << 1, // SqlMapper.ICustomQueryParameter: the value binds itself + } + + internal static bool IsCustomQueryParameter(ITypeSymbol? type) + { + static bool IsTheInterface(ITypeSymbol symbol) => symbol is INamedTypeSymbol + { + Name: "ICustomQueryParameter", + Arity: 0, + TypeKind: TypeKind.Interface, + ContainingType: + { + Name: "SqlMapper", + ContainingNamespace: + { + Name: "Dapper", + ContainingNamespace.IsGlobalNamespace: true + } + } + }; + if (type is null) return false; + if (IsTheInterface(type)) return true; + foreach (var iface in type.AllInterfaces) + { + if (IsTheInterface(iface)) return true; + } + return false; } internal static bool IsCancellationToken(ITypeSymbol? type) @@ -623,6 +650,8 @@ public DapperSpecialType DapperSpecialType } }) return DapperSpecialType.DbString; + if (IsCustomQueryParameter(CodeType)) return DapperSpecialType.CustomQueryParameter; + return DapperSpecialType.None; } } diff --git a/test/Dapper.AOT.Test/Interceptors/CustomParameters.input.cs b/test/Dapper.AOT.Test/Interceptors/CustomParameters.input.cs new file mode 100644 index 00000000..eaa52dfe --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/CustomParameters.input.cs @@ -0,0 +1,43 @@ +using Dapper; +using System.Data; +using System.Data.Common; + +[module: DapperAot] + +public static class Foo +{ + static void SomeCode(DbConnection connection, SqlMapper.ICustomQueryParameter tvp) + { + // ICustomQueryParameter members add themselves (TVPs are the common case); a member + // typed as the interface, a class, and a struct - the struct needs no null test + _ = connection.Query("select count(1) from @ids", new { ids = tvp }); + _ = connection.Query("select count(1) from @ids where Id = @id", new { ids = new CustomClass(), id = 42 }); + _ = connection.Query("select count(1) from @ids", new { ids = new CustomStruct() }); + + // skipped: PostProcess reads output parameters back by index, and a self-binding + // member contributes an unknowable number of parameters before them + _ = connection.Execute("exec SomeProc @ids, @total out", new WithOutput { ids = new CustomClass() }); + + // skipped: multi-exec batch reuse updates parameters in-place + _ = connection.Execute("exec SomeProc @ids", new[] { new WithCustom(), new WithCustom() }); + } + + public class CustomClass : SqlMapper.ICustomQueryParameter + { + public void AddParameter(IDbCommand command, string name) { } + } + public struct CustomStruct : SqlMapper.ICustomQueryParameter + { + public void AddParameter(IDbCommand command, string name) { } + } + public class WithOutput + { + public CustomClass ids { get; set; } + [DbValue(Direction = ParameterDirection.Output)] + public int total { get; set; } + } + public class WithCustom + { + public CustomClass ids { get; set; } + } +} diff --git a/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.cs b/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.cs new file mode 100644 index 00000000..dd032334 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.cs @@ -0,0 +1,162 @@ +#nullable enable +#pragma warning disable IDE0078 // unnecessary suppression is necessary +#pragma warning disable CS9270 // SDK-dependent change to interceptors usage +namespace Dapper.AOT // interceptors must be in a known namespace +{ + file static class DapperGeneratedInterceptors + { + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\CustomParameters.input.cs", 13, 24)] + internal static global::System.Collections.Generic.IEnumerable Query0(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, TypedResult, HasParameters, Buffered, Text, KnownParameters + // takes parameter: + // parameter map: ids + // returns data: int + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text); + global::System.Diagnostics.Debug.Assert(buffered is true); + global::System.Diagnostics.Debug.Assert(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory0.Instance).QueryBuffered(param, global::Dapper.RowFactory.Inbuilt.Value()); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\CustomParameters.input.cs", 14, 24)] + internal static global::System.Collections.Generic.IEnumerable Query1(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, TypedResult, HasParameters, Buffered, Text, KnownParameters + // takes parameter: + // parameter map: id ids + // returns data: int + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text); + global::System.Diagnostics.Debug.Assert(buffered is true); + global::System.Diagnostics.Debug.Assert(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory1.Instance).QueryBuffered(param, global::Dapper.RowFactory.Inbuilt.Value()); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\CustomParameters.input.cs", 15, 24)] + internal static global::System.Collections.Generic.IEnumerable Query2(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, TypedResult, HasParameters, Buffered, Text, KnownParameters + // takes parameter: + // parameter map: ids + // returns data: int + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text); + global::System.Diagnostics.Debug.Assert(buffered is true); + global::System.Diagnostics.Debug.Assert(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory2.Instance).QueryBuffered(param, global::Dapper.RowFactory.Inbuilt.Value()); + + } + + private class CommonCommandFactory : global::Dapper.CommandFactory + { + public override global::System.Data.Common.DbCommand GetCommand(global::System.Data.Common.DbConnection connection, string sql, global::System.Data.CommandType commandType, T args) + { + var cmd = base.GetCommand(connection, sql, commandType, args); + // apply special per-provider command initialization logic for OracleCommand + if (cmd is global::Oracle.ManagedDataAccess.Client.OracleCommand cmd0) + { + cmd0.BindByName = true; + cmd0.InitialLONGFetchSize = -1; + + } + return cmd; + } + + } + + private static readonly CommonCommandFactory DefaultCommandFactory = new(); + + private sealed class CommandFactory0 : CommonCommandFactory // + { + internal static readonly CommandFactory0 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Dapper.SqlMapper.ICustomQueryParameter)! }); // expected shape + var ps = cmd.Parameters; + if (typed.ids is null) throw new global::System.InvalidOperationException("Member 'ids' is an ICustomQueryParameter and cannot be null"); + typed.ids.AddParameter(cmd.Command!, "ids"); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Dapper.SqlMapper.ICustomQueryParameter)! }); // expected shape + var ps = cmd.Parameters; + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory // + { + internal static readonly CommandFactory1 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Foo.CustomClass)!, id = default(int) }); // expected shape + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + if (typed.ids is null) throw new global::System.InvalidOperationException("Member 'ids' is an ICustomQueryParameter and cannot be null"); + typed.ids.AddParameter(cmd.Command!, "ids"); + + p = cmd.CreateParameter(); + p.ParameterName = "id"; + p.DbType = global::System.Data.DbType.Int32; + p.Direction = global::System.Data.ParameterDirection.Input; + p.Value = AsValue(typed.id); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Foo.CustomClass)!, id = default(int) }); // expected shape + var ps = cmd.Parameters; + ps[1].Value = AsValue(typed.id); + + } + + } + + private sealed class CommandFactory2 : CommonCommandFactory // + { + internal static readonly CommandFactory2 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Foo.CustomStruct) }); // expected shape + var ps = cmd.Parameters; + typed.ids.AddParameter(cmd.Command!, "ids"); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Foo.CustomStruct) }); // expected shape + var ps = cmd.Parameters; + + } + + } + + + } +} +namespace System.Runtime.CompilerServices +{ + // this type is needed by the compiler to implement interceptors - it doesn't need to + // come from the runtime itself, though + + [global::System.Diagnostics.Conditional("DEBUG")] // not needed post-build, so: evaporate + [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] + sealed file class InterceptsLocationAttribute : global::System.Attribute + { + public InterceptsLocationAttribute(string path, int lineNumber, int columnNumber) + { + _ = path; + _ = lineNumber; + _ = columnNumber; + } + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.netfx.cs b/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.netfx.cs new file mode 100644 index 00000000..dd032334 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.netfx.cs @@ -0,0 +1,162 @@ +#nullable enable +#pragma warning disable IDE0078 // unnecessary suppression is necessary +#pragma warning disable CS9270 // SDK-dependent change to interceptors usage +namespace Dapper.AOT // interceptors must be in a known namespace +{ + file static class DapperGeneratedInterceptors + { + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\CustomParameters.input.cs", 13, 24)] + internal static global::System.Collections.Generic.IEnumerable Query0(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, TypedResult, HasParameters, Buffered, Text, KnownParameters + // takes parameter: + // parameter map: ids + // returns data: int + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text); + global::System.Diagnostics.Debug.Assert(buffered is true); + global::System.Diagnostics.Debug.Assert(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory0.Instance).QueryBuffered(param, global::Dapper.RowFactory.Inbuilt.Value()); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\CustomParameters.input.cs", 14, 24)] + internal static global::System.Collections.Generic.IEnumerable Query1(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, TypedResult, HasParameters, Buffered, Text, KnownParameters + // takes parameter: + // parameter map: id ids + // returns data: int + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text); + global::System.Diagnostics.Debug.Assert(buffered is true); + global::System.Diagnostics.Debug.Assert(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory1.Instance).QueryBuffered(param, global::Dapper.RowFactory.Inbuilt.Value()); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\CustomParameters.input.cs", 15, 24)] + internal static global::System.Collections.Generic.IEnumerable Query2(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Query, TypedResult, HasParameters, Buffered, Text, KnownParameters + // takes parameter: + // parameter map: ids + // returns data: int + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.Text); + global::System.Diagnostics.Debug.Assert(buffered is true); + global::System.Diagnostics.Debug.Assert(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory2.Instance).QueryBuffered(param, global::Dapper.RowFactory.Inbuilt.Value()); + + } + + private class CommonCommandFactory : global::Dapper.CommandFactory + { + public override global::System.Data.Common.DbCommand GetCommand(global::System.Data.Common.DbConnection connection, string sql, global::System.Data.CommandType commandType, T args) + { + var cmd = base.GetCommand(connection, sql, commandType, args); + // apply special per-provider command initialization logic for OracleCommand + if (cmd is global::Oracle.ManagedDataAccess.Client.OracleCommand cmd0) + { + cmd0.BindByName = true; + cmd0.InitialLONGFetchSize = -1; + + } + return cmd; + } + + } + + private static readonly CommonCommandFactory DefaultCommandFactory = new(); + + private sealed class CommandFactory0 : CommonCommandFactory // + { + internal static readonly CommandFactory0 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Dapper.SqlMapper.ICustomQueryParameter)! }); // expected shape + var ps = cmd.Parameters; + if (typed.ids is null) throw new global::System.InvalidOperationException("Member 'ids' is an ICustomQueryParameter and cannot be null"); + typed.ids.AddParameter(cmd.Command!, "ids"); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Dapper.SqlMapper.ICustomQueryParameter)! }); // expected shape + var ps = cmd.Parameters; + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory // + { + internal static readonly CommandFactory1 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Foo.CustomClass)!, id = default(int) }); // expected shape + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + if (typed.ids is null) throw new global::System.InvalidOperationException("Member 'ids' is an ICustomQueryParameter and cannot be null"); + typed.ids.AddParameter(cmd.Command!, "ids"); + + p = cmd.CreateParameter(); + p.ParameterName = "id"; + p.DbType = global::System.Data.DbType.Int32; + p.Direction = global::System.Data.ParameterDirection.Input; + p.Value = AsValue(typed.id); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Foo.CustomClass)!, id = default(int) }); // expected shape + var ps = cmd.Parameters; + ps[1].Value = AsValue(typed.id); + + } + + } + + private sealed class CommandFactory2 : CommonCommandFactory // + { + internal static readonly CommandFactory2 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Foo.CustomStruct) }); // expected shape + var ps = cmd.Parameters; + typed.ids.AddParameter(cmd.Command!, "ids"); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::Foo.CustomStruct) }); // expected shape + var ps = cmd.Parameters; + + } + + } + + + } +} +namespace System.Runtime.CompilerServices +{ + // this type is needed by the compiler to implement interceptors - it doesn't need to + // come from the runtime itself, though + + [global::System.Diagnostics.Conditional("DEBUG")] // not needed post-build, so: evaporate + [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] + sealed file class InterceptsLocationAttribute : global::System.Attribute + { + public InterceptsLocationAttribute(string path, int lineNumber, int columnNumber) + { + _ = path; + _ = lineNumber; + _ = columnNumber; + } + } +} \ No newline at end of file diff --git a/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.netfx.txt b/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.netfx.txt new file mode 100644 index 00000000..a65e2434 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.netfx.txt @@ -0,0 +1,4 @@ +Generator produced 1 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 3 of 5 enabled call-sites (0 unsupported API, 2 skipped due to diagnostics) using 3 interceptors, 3 commands and 0 readers diff --git a/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.txt b/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.txt new file mode 100644 index 00000000..a65e2434 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/CustomParameters.output.txt @@ -0,0 +1,4 @@ +Generator produced 1 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 3 of 5 enabled call-sites (0 unsupported API, 2 skipped due to diagnostics) using 3 interceptors, 3 commands and 0 readers From 4599622e15a47f212382756cd6897adc6953b888 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 19 Aug 2026 10:19:39 +0100 Subject: [PATCH 2/2] Tick the parity cells this lands --- notes/parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/parity.md b/notes/parity.md index 2f7a31c4..518f5bfa 100644 --- a/notes/parity.md +++ b/notes/parity.md @@ -44,7 +44,7 @@ Two levers change several complexity scores and are worth naming up front: | `GetRowParser(reader)` | ✅ | — | — | | | `GetRowParser(reader, Type concreteType, ...)` | ❌ | med | low* | discriminator/polymorphism pattern; dictionary lookup once types are announced | | `Parse` / `Parse(Type)` / `Parse` (dynamic) | ❌ ❓ | low | low | same reader machinery, different entry point | -| `AsTableValuedParameter` (`DataTable` / `SqlDataRecord`) | ❓ | med | med | **PR #198 open** covers it (the result *is* an `ICustomQueryParameter`); a bare `DataTable` member rides the type-handler story instead (vanilla registers `DataTableHandler` by default) | +| `AsTableValuedParameter` (`DataTable` / `SqlDataRecord`) | ⚠️ | low | — | the result *is* an `ICustomQueryParameter`, so covered above; a bare `DataTable` member rides the type-handler story instead (vanilla registers `DataTableHandler` by default) | | `AsList` | n/a | — | — | trivial helper; confirm it doesn't count as a candidate site | | `GetTypeDeserializer(Type, reader, startBound, length, ...)` | ❌ | low-med | low* | a valid raw-materializer API, not mere plumbing: with announced types it's the same dispatch map, returning a boxed `Func`. Its generic strengthening **already exists**: `GetRowParser` (same slicing knobs), which AOT supports | | `CreateParamInfoGenerator(Identity, ...)` | ❌ | low | med | the raw parameter-binder factory; **no generic counterpart exists in Dapper** — see "Strengthened APIs" in [type-vs-generic.md](type-vs-generic.md) for the proposed `` form | @@ -61,7 +61,7 @@ Two levers change several complexity scores and are worth naming up front: | fields as members | ❓ | low | low | verify | | `DynamicParameters` | ❌ | **high** | high | **PR #195 open**: delegate to the bag's own protocol (pairs with Dapper #2225); covers subclasses via interface dispatch. Templates ride on the same path | | `SqlMapper.IDynamicParameters` (custom impls) | ❌ | low-med | med | interface receives the `IDbCommand`, so callable directly — blocked on `Identity` (Dapper-internal) in the signature; owning Dapper permits an AOT-friendly overload | -| `SqlMapper.ICustomQueryParameter` | ❌ ❓ | med | low | **PR #198 open**: generated code calls it, with vanilla's null semantics; uncovered a teardown bug (**PR #199**: parameters must be cleared on dispose, as vanilla does) | +| `SqlMapper.ICustomQueryParameter` | ✅ | — | — | generated code calls `AddParameter(command, name)` with vanilla's null semantics; self-binding guards as for list expansion. Uncovered a teardown bug (PR #199: parameters must be cleared on dispose, as vanilla does) | | `IParameterLookup` / `IParameterCallbacks` | ❌ ❓ | low | low-med | obscure but public | | `DbString` | ✅ | — | — | DAP048 nudges to `[DbValue]`; keep the Dapper spelling, the corpus uses it | | output / return params via `[DbValue(Direction=...)]` | ⚠️ | — | — | AOT spelling works; Dapper spelling rides on `DynamicParameters` above |