Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions notes/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Two levers change several complexity scores and are worth naming up front:
| `GetRowParser<T>(reader)` | ✅ | — | — | |
| `GetRowParser(reader, Type concreteType, ...)` | ❌ | med | low* | discriminator/polymorphism pattern; dictionary lookup once types are announced |
| `Parse<T>` / `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<T>` | 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<DbDataReader, object>`. Its generic strengthening **already exists**: `GetRowParser<T>` (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 `<T>` form |
Expand All @@ -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 |
Expand Down
41 changes: 30 additions & 11 deletions src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -154,7 +156,8 @@ public override int GetHashCode()
public string TypeName { get; } // emitted (Append) form, for Parse<T> 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;
Expand All @@ -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;
Expand All @@ -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<int>("Size");
Expand All @@ -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<byte>("Precision"), member.TryGetValue<byte>("Scale"),
CodeWriter.GetAppendTypeName(member.CodeType!));
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/Dapper.AOT.Analyzers/Internal/Inspection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -623,6 +650,8 @@ public DapperSpecialType DapperSpecialType
}
}) return DapperSpecialType.DbString;

if (IsCustomQueryParameter(CodeType)) return DapperSpecialType.CustomQueryParameter;

return DapperSpecialType.None;
}
}
Expand Down
43 changes: 43 additions & 0 deletions test/Dapper.AOT.Test/Interceptors/CustomParameters.input.cs
Original file line number Diff line number Diff line change
@@ -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<int>("select count(1) from @ids", new { ids = tvp });
_ = connection.Query<int>("select count(1) from @ids where Id = @id", new { ids = new CustomClass(), id = 42 });
_ = connection.Query<int>("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; }
}
}
Loading