From 461833936f82f4885dd0323118aca96a40a38644 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 18 Aug 2026 19:39:32 +0100 Subject: [PATCH 1/3] Support list expansion (where X in @ids) by delegating to Dapper's own PackListParameters An expandable (enumerable) member previously bound as a single raw parameter, which fails at execution; now the generated AddParameters delegates to the public (obsolete, "library usage only") SqlMapper.PackListParameters, which owns the whole in-list contract: the SQL rewrite (including the empty-list and optimize-hint forms), per-item parameters, DbString items, padding and string_split settings, and provider array support. Calling the existing API means this works against every shipped Dapper, so no feature-detection diagnostic is needed; the alternative - a fresh non-obsolete wrapper in Dapper, probe-gated like the DynamicParameters overload - is a fair follow-up if we would rather not lean on an [Obsolete] member from generated code. Guard rails, all parse-side so the call-sites stay on vanilla Dapper rather than misbehave: - command caching is disabled for a factory with an expandable member (the parameter shape varies per call), and CanPrepare is cleared; - multi-exec over elements with an expandable member is skipped (batch reuse updates parameters in-place, which cannot re-expand a list whose size changed); - an expandable member alongside an output/return parameter is skipped (PostProcess reads those back by index, and expansion shifts every index after it). The shared "p" local in AddParameters is now emitted only when some member still needs it, since a factory whose members all expand otherwise declares it unused (CS0168 in the consumer's build). New ListExpansion fixture covers the three working shapes and both skips; the TsqlTips golden moves off the raw bind, which was the broken behaviour. --- .../DapperInterceptorGenerator.cs | 87 ++++++++- .../CodeAnalysis/Model/ParamPlan.cs | 9 +- .../Interceptors/ListExpansion.input.cs | 37 ++++ .../Interceptors/ListExpansion.output.cs | 166 ++++++++++++++++++ .../ListExpansion.output.netfx.cs | 166 ++++++++++++++++++ .../ListExpansion.output.netfx.txt | 4 + .../Interceptors/ListExpansion.output.txt | 4 + .../Interceptors/TsqlTips.output.cs | 10 +- .../Interceptors/TsqlTips.output.netfx.cs | 10 +- 9 files changed, 471 insertions(+), 22 deletions(-) create mode 100644 test/Dapper.AOT.Test/Interceptors/ListExpansion.input.cs create mode 100644 test/Dapper.AOT.Test/Interceptors/ListExpansion.output.cs create mode 100644 test/Dapper.AOT.Test/Interceptors/ListExpansion.output.netfx.cs create mode 100644 test/Dapper.AOT.Test/Interceptors/ListExpansion.output.netfx.txt create mode 100644 test/Dapper.AOT.Test/Interceptors/ListExpansion.output.txt diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs index 4a527d60..d1eb0516 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs @@ -102,6 +102,31 @@ 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) + { + if (plan is not null) + { + foreach (var member in plan.Members) + { + if (member.IsMapped && member.IsExpandable) return true; + } + } + return false; + } + + private static bool HasNonInputMember(ParamPlan? plan) + { + if (plan is not null) + { + foreach (var member in plan.Members) + { + if (member.IsMapped && !member.IsCancellation && !member.IsRowCount + && member.Direction != System.Data.ParameterDirection.Input) return true; + } + } + return false; + } + private static InterceptedMethod ProjectMethod(IMethodSymbol method) { var args = method.Parameters; @@ -152,6 +177,20 @@ private static InterceptedMethod ProjectMethod(IMethodSymbol method) var map = MemberMap.CreateForParameters(argExpression); 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)) + { + // 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 + return new SkippedSourceState(new LocationSnapshot(location), flags); + } + if (HasExpandableMember(parameterPlan) && HasNonInputMember(parameterPlan)) + { + // PostProcess addresses output/return parameters by *index*, and an expanded + // list contributes a runtime-variable number of parameters before them; leave + // such call-sites on vanilla Dapper rather than read back the wrong slot + return new SkippedSourceState(new LocationSnapshot(location), flags); + } if (flags.HasAny(OperationFlags.CacheCommand)) { bool canBeCached = true; @@ -160,6 +199,10 @@ private static InterceptedMethod ProjectMethod(IMethodSymbol method) { canBeCached = false; } + else if (HasExpandableMember(parameterPlan)) + { + canBeCached = false; // list-expansion changes the parameter shape per call + } if (!canBeCached) flags &= ~OperationFlags.CacheCommand; } @@ -171,7 +214,7 @@ private static InterceptedMethod ProjectMethod(IMethodSymbol method) return new SuccessSourceState(new LocationSnapshot(location), InterceptorFilePath(ctx, location), languageVersion, ProjectMethod(op.TargetMethod), flags, sql, RowPlan.Create(resultType, additionalState?.QueryColumns ?? default), - ParamPlan.Create(argExpression?.Type), parameterMap, additionalState); + parameterPlan, parameterMap, additionalState); } catch (Exception ex) { @@ -1149,6 +1192,23 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co var planMembers = parameterType.Members; if (planMembers.IsEmpty) return; + // Add mode uses a shared "p" local, declared at method scope because the per-member + // Include(...) guards each open their own block; a factory whose members all expand + // (PackListParameters) never touches it, and declaring it then is CS0168 + bool needsParameterLocal = false; + if (mode == WriteArgsMode.Add) + { + foreach (var member in planMembers) + { + if (member.IsMapped && !member.IsCancellation && !member.IsRowCount && !member.IsExpandable + && SqlTools.IncludeParameter(map, member.CodeName, out _)) + { + needsParameterLocal = true; + break; + } + } + } + foreach (var member in planMembers) { if (!member.IsMapped) continue; @@ -1203,11 +1263,9 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co if (first && mode != WriteArgsMode.GetCancellationToken) { sb.Append("var ps = cmd.Parameters;").NewLine(); - switch (mode) + if (needsParameterLocal) { - case WriteArgsMode.Add: - sb.Append("global::System.Data.Common.DbParameter p;").NewLine(); - break; + sb.Append("global::System.Data.Common.DbParameter p;").NewLine(); } first = false; } @@ -1231,6 +1289,19 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co switch (mode) { case WriteArgsMode.Add: + if (member.IsExpandable) + { + // list-expansion (where X in @ids): delegate to Dapper's own implementation, + // which owns the whole contract - SQL rewrite (including the empty-list and + // optimize-hint forms), per-item parameters, DbString items, padding and + // string_split settings, and provider array support + flags &= ~WriteArgsFlags.CanPrepare; // parameter shape varies by list size + sb.Append("#pragma warning disable CS0618 // list-expansion: this *is* the library usage").NewLine() + .Append("global::Dapper.SqlMapper.PackListParameters(cmd.Command!, ").AppendVerbatimLiteral(member.DbName) + .Append(", ").Append(source).Append(".").Append(member.CodeName).Append(");").NewLine() + .Append("#pragma warning restore CS0618").NewLine(); + break; + } sb.Append("p = cmd.CreateParameter();").NewLine(); sb.Append("p.ParameterName = ").AppendVerbatimLiteral(member.DbName).Append(";").NewLine(); @@ -1298,6 +1369,12 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co } break; case WriteArgsMode.Update: + if (member.IsExpandable) + { + // update is only reachable via command-cache reuse and batch, both of + // which are refused for expandable members at parse + break; + } if (member.IsDbString) { ctx.GeneratorContext.IncludeGenerationType(IncludedGeneration.DbStringHelpers); diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs index 739bf621..4e278b76 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs @@ -144,6 +144,7 @@ public override int GetHashCode() public string DbName { get; } public ParameterDirection Direction { get; } public bool IsDbString { get; } + public bool IsExpandable { get; } // enumerable member: list-expansion (in @ids) applies 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 @@ -153,7 +154,7 @@ 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 hasDbType, string? dbTypeName, int? effectiveSize, + ParameterDirection direction, bool isDbString, bool isExpandable, bool hasDbType, string? dbTypeName, int? effectiveSize, bool useSetValueWithDefaultSize, byte? precision, byte? scale, string typeName) { IsMapped = isMapped; @@ -163,6 +164,7 @@ private ParamMember(bool isMapped, bool isCancellation, bool isRowCount, string DbName = dbName; Direction = direction; IsDbString = isDbString; + IsExpandable = isExpandable; HasDbType = hasDbType; DbTypeName = dbTypeName; EffectiveSize = effectiveSize; @@ -176,7 +178,7 @@ public static ParamMember Create(in ElementMember member) { if (!member.IsMapped) { - return new(false, false, false, "", "", default, false, false, null, null, false, null, null, ""); + return new(false, false, false, "", "", default, false, false, false, null, null, false, null, null, ""); } var dbType = member.GetDbType(out _); var size = member.TryGetValue("Size"); @@ -200,7 +202,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.Direction, member.DapperSpecialType is DapperSpecialType.DbString, member.IsExpandable, dbType is not null, dbType?.ToString(), size, useSetValueWithDefaultSize, member.TryGetValue("Precision"), member.TryGetValue("Scale"), CodeWriter.GetAppendTypeName(member.CodeType!)); @@ -213,6 +215,7 @@ public bool Equals(ParamMember other) => IsMapped == other.IsMapped && string.Equals(DbName, other.DbName, StringComparison.Ordinal) && Direction == other.Direction && IsDbString == other.IsDbString + && IsExpandable == other.IsExpandable && HasDbType == other.HasDbType && string.Equals(DbTypeName, other.DbTypeName, StringComparison.Ordinal) && EffectiveSize == other.EffectiveSize diff --git a/test/Dapper.AOT.Test/Interceptors/ListExpansion.input.cs b/test/Dapper.AOT.Test/Interceptors/ListExpansion.input.cs new file mode 100644 index 00000000..e0ed4f5c --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/ListExpansion.input.cs @@ -0,0 +1,37 @@ +using Dapper; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; + +[module: DapperAot] + +public static class Foo +{ + static void SomeCode(DbConnection connection) + { + // list expansion: the member binds via Dapper's own PackListParameters, which owns + // the whole in-list contract (rewrite, empty form, padding, split, DbString items) + _ = connection.Query("select Id from Customers where Id in @ids", new { ids = new[] { 1, 2, 3 } }); + _ = connection.Query("select Id from Customers where Id in @ids and Region = @region", new { ids = new List { 1 }, region = "north" }); + _ = connection.Query("select Name from Customers where Name in @names", new { names = new[] { "a", "b" } }); + + // skipped: expansion adds a runtime-variable number of parameters, so the by-index + // read-back of the output parameter cannot be trusted; stays on vanilla Dapper + _ = connection.Execute("declare @dummy int; select @total = count(1) from Customers where Id in @ids", new WithOutput { ids = new[] { 1, 2 } }); + + // skipped: multi-exec batch reuse updates parameters in-place, which cannot re-expand + // a list whose size changed between items; stays on vanilla Dapper + _ = connection.Execute("insert Audit (Id) select v from @ids", new[] { new WithList(), new WithList() }); + } + + public class WithOutput + { + public int[] ids { get; set; } = System.Array.Empty(); + [DbValue(Direction = ParameterDirection.Output)] + public int total { get; set; } + } + public class WithList + { + public int[] ids { get; set; } = System.Array.Empty(); + } +} diff --git a/test/Dapper.AOT.Test/Interceptors/ListExpansion.output.cs b/test/Dapper.AOT.Test/Interceptors/ListExpansion.output.cs new file mode 100644 index 00000000..41e198b8 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/ListExpansion.output.cs @@ -0,0 +1,166 @@ +#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\\ListExpansion.input.cs", 14, 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\\ListExpansion.input.cs", 15, 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: ids, string region> + // parameter map: ids region + // 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\\ListExpansion.input.cs", 16, 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: names + // 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(int[])! }); // expected shape + var ps = cmd.Parameters; + #pragma warning disable CS0618 // list-expansion: this *is* the library usage + global::Dapper.SqlMapper.PackListParameters(cmd.Command!, "ids", typed.ids); + #pragma warning restore CS0618 + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(int[])! }); // expected shape + var ps = cmd.Parameters; + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory // ids, string region> + { + 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::System.Collections.Generic.List)!, region = default(string)! }); // expected shape + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + #pragma warning disable CS0618 // list-expansion: this *is* the library usage + global::Dapper.SqlMapper.PackListParameters(cmd.Command!, "ids", typed.ids); + #pragma warning restore CS0618 + + p = cmd.CreateParameter(); + p.ParameterName = "region"; + p.DbType = global::System.Data.DbType.String; + p.Direction = global::System.Data.ParameterDirection.Input; + SetValueWithDefaultSize(p, typed.region); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::System.Collections.Generic.List)!, region = default(string)! }); // expected shape + var ps = cmd.Parameters; + ps[1].Value = AsValue(typed.region); + + } + + } + + 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 { names = default(string[])! }); // expected shape + var ps = cmd.Parameters; + #pragma warning disable CS0618 // list-expansion: this *is* the library usage + global::Dapper.SqlMapper.PackListParameters(cmd.Command!, "names", typed.names); + #pragma warning restore CS0618 + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { names = default(string[])! }); // 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/ListExpansion.output.netfx.cs b/test/Dapper.AOT.Test/Interceptors/ListExpansion.output.netfx.cs new file mode 100644 index 00000000..41e198b8 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/ListExpansion.output.netfx.cs @@ -0,0 +1,166 @@ +#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\\ListExpansion.input.cs", 14, 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\\ListExpansion.input.cs", 15, 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: ids, string region> + // parameter map: ids region + // 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\\ListExpansion.input.cs", 16, 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: names + // 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(int[])! }); // expected shape + var ps = cmd.Parameters; + #pragma warning disable CS0618 // list-expansion: this *is* the library usage + global::Dapper.SqlMapper.PackListParameters(cmd.Command!, "ids", typed.ids); + #pragma warning restore CS0618 + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(int[])! }); // expected shape + var ps = cmd.Parameters; + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory // ids, string region> + { + 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::System.Collections.Generic.List)!, region = default(string)! }); // expected shape + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + #pragma warning disable CS0618 // list-expansion: this *is* the library usage + global::Dapper.SqlMapper.PackListParameters(cmd.Command!, "ids", typed.ids); + #pragma warning restore CS0618 + + p = cmd.CreateParameter(); + p.ParameterName = "region"; + p.DbType = global::System.Data.DbType.String; + p.Direction = global::System.Data.ParameterDirection.Input; + SetValueWithDefaultSize(p, typed.region); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { ids = default(global::System.Collections.Generic.List)!, region = default(string)! }); // expected shape + var ps = cmd.Parameters; + ps[1].Value = AsValue(typed.region); + + } + + } + + 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 { names = default(string[])! }); // expected shape + var ps = cmd.Parameters; + #pragma warning disable CS0618 // list-expansion: this *is* the library usage + global::Dapper.SqlMapper.PackListParameters(cmd.Command!, "names", typed.names); + #pragma warning restore CS0618 + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { names = default(string[])! }); // 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/ListExpansion.output.netfx.txt b/test/Dapper.AOT.Test/Interceptors/ListExpansion.output.netfx.txt new file mode 100644 index 00000000..a65e2434 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/ListExpansion.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/ListExpansion.output.txt b/test/Dapper.AOT.Test/Interceptors/ListExpansion.output.txt new file mode 100644 index 00000000..a65e2434 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/ListExpansion.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 diff --git a/test/Dapper.AOT.Test/Interceptors/TsqlTips.output.cs b/test/Dapper.AOT.Test/Interceptors/TsqlTips.output.cs index 1be948cd..0bdef036 100644 --- a/test/Dapper.AOT.Test/Interceptors/TsqlTips.output.cs +++ b/test/Dapper.AOT.Test/Interceptors/TsqlTips.output.cs @@ -287,19 +287,15 @@ public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? { var typed = Cast(args, static () => new { ids = default(int[])! }); // expected shape var ps = cmd.Parameters; - global::System.Data.Common.DbParameter p; - p = cmd.CreateParameter(); - p.ParameterName = "ids"; - p.Direction = global::System.Data.ParameterDirection.Input; - p.Value = AsValue(typed.ids); - ps.Add(p); + #pragma warning disable CS0618 // list-expansion: this *is* the library usage + global::Dapper.SqlMapper.PackListParameters(cmd.Command!, "ids", typed.ids); + #pragma warning restore CS0618 } public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) { var typed = Cast(args, static () => new { ids = default(int[])! }); // expected shape var ps = cmd.Parameters; - ps[0].Value = AsValue(typed.ids); } diff --git a/test/Dapper.AOT.Test/Interceptors/TsqlTips.output.netfx.cs b/test/Dapper.AOT.Test/Interceptors/TsqlTips.output.netfx.cs index 1be948cd..0bdef036 100644 --- a/test/Dapper.AOT.Test/Interceptors/TsqlTips.output.netfx.cs +++ b/test/Dapper.AOT.Test/Interceptors/TsqlTips.output.netfx.cs @@ -287,19 +287,15 @@ public override void AddParameters(in global::Dapper.UnifiedCommand cmd, object? { var typed = Cast(args, static () => new { ids = default(int[])! }); // expected shape var ps = cmd.Parameters; - global::System.Data.Common.DbParameter p; - p = cmd.CreateParameter(); - p.ParameterName = "ids"; - p.Direction = global::System.Data.ParameterDirection.Input; - p.Value = AsValue(typed.ids); - ps.Add(p); + #pragma warning disable CS0618 // list-expansion: this *is* the library usage + global::Dapper.SqlMapper.PackListParameters(cmd.Command!, "ids", typed.ids); + #pragma warning restore CS0618 } public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) { var typed = Cast(args, static () => new { ids = default(int[])! }); // expected shape var ps = cmd.Parameters; - ps[0].Value = AsValue(typed.ids); } From f1c9a4f67bd375f5685e0622f76b022243ccea06 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 19 Aug 2026 10:19:37 +0100 Subject: [PATCH 2/3] Tick the parity cells this lands --- notes/parity.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/notes/parity.md b/notes/parity.md index 2f7a31c4..7705643f 100644 --- a/notes/parity.md +++ b/notes/parity.md @@ -65,7 +65,7 @@ Two levers change several complexity scores and are worth naming up front: | `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 | -| list expansion (`in @ids`) | ❌ ❓ | **high** | med | **PR #197 open**: delegates to vanilla's `PackListParameters`, which owns the whole contract — no runtime rewrite helper needed after all. [tokens.md](tokens.md) §2 | +| list expansion (`in @ids`) | ✅ | — | — | delegates to vanilla's `PackListParameters`, which owns the whole contract (rewrite, empty form, padding, split, DbString items). Self-binding guards: no command caching, no multi-exec, not alongside output params. [tokens.md](tokens.md) §2 | | literal injection (`{=name}`) | ❌ ❓ | med | low-med | formatting rules compile-time decidable. [tokens.md](tokens.md) §3 | | pseudo-positional (`?foo?`) | ❌ ❓ | low | med | OleDb/Access corner. [tokens.md](tokens.md) §4 | | enum / nullable / `char` / `Guid` params | ⚠️❓ | med | low | verify edge conversions vs Dapper | @@ -102,12 +102,12 @@ Two levers change several complexity scores and are worth naming up front: | feature | AOT status | impact | complexity | notes | | --- | --- | --- | --- | --- | | `Settings.CommandTimeout` (global default) | ❓ | med | low | AOT has per-site args + `[CommandProperty]`; needs a global knob | -| `Settings.InListStringSplitCount` | ❌ | med | low* | *after* list expansion; SQL Server plan-stability win | -| `Settings.PadListExpansions` | ❌ | low-med | low* | same | +| `Settings.InListStringSplitCount` | ✅ | — | — | honored for free: it lives inside `PackListParameters`, which list expansion delegates to | +| `Settings.PadListExpansions` | ✅ | — | — | same — honored inside `PackListParameters` | | `Settings.UseSingleResult/UseSingleRowOptimization` | ❓ | low | low | **PR #196 open**: verification found a real divergence (AOT hardcoded the opt-in flags; swallowed trailing errors, 10x slower async) — now matches vanilla's default | | `Settings.FetchSize` (Oracle) | ⚠️ | low | low | `GlobalFetchSize` exists; verify | | `SqlMapper.ConnectionStringComparer` | 🚫? | **zero-ish** | — | exists to partition the runtime identity/cache — a concept AOT doesn't have | -| `FeatureSupport` (per-provider null-array quirks) | ❓ | low | low | folds into list-expansion helper | +| `FeatureSupport` (per-provider null-array quirks) | ✅ | — | — | honored inside `PackListParameters` (the arrays branch is its first test) | ## 5. Sibling packages in the Dapper repo From 4ff5c373ca3e1268ddfc6dbd5c92faf2cb95150f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 20 Aug 2026 09:47:33 +0100 Subject: [PATCH 3/3] Support ICustomQueryParameter members (TVPs): the value adds itself (#198) * 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. * Tick the parity cells this lands --- notes/parity.md | 4 +- .../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 + 9 files changed, 446 insertions(+), 15 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/notes/parity.md b/notes/parity.md index 7705643f..8e7533a4 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 | 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