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
12 changes: 6 additions & 6 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,11 +61,11 @@ Two levers change several complexity scores and are worth naming up front:
| fields as members | ❓ | low | low | verify |
| `DynamicParameters` | ✅ | — | — | delegates to the bag's own protocol, so templates, per-param options, `Get<T>` and output callbacks all ride along; subclasses covered via interface dispatch. Needs a Dapper with the identity-free overload (Dapper #2225) — probe-gated, DAP052 otherwise |
| `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 |
| 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 |
Expand Down Expand Up @@ -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` | ✅ | — | — | verification found a real divergence (AOT hardcoded the opt-in flags; swallowed trailing errors, 10x slower async); now matches vanilla's default — no flags. The runtime knobs stay unread, with the opt-in location noted in code |
| `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

Expand Down
106 changes: 101 additions & 5 deletions src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ private static string InterceptorFilePath(in ParseState ctx, Location location)
return ctx.SemanticModel.Compilation.Options.SourceReferenceResolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath;
}

// 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 || member.IsCustom)) 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;
Expand Down Expand Up @@ -152,6 +179,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 } && HasSelfBindingMember(element))
{
// 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 (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
// 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;
Expand All @@ -160,6 +201,10 @@ private static InterceptedMethod ProjectMethod(IMethodSymbol method)
{
canBeCached = false;
}
else if (HasSelfBindingMember(parameterPlan))
{
canBeCached = false; // self-binding members change the parameter shape per call
}

if (!canBeCached) flags &= ~OperationFlags.CacheCommand;
}
Expand All @@ -171,7 +216,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)
{
Expand Down Expand Up @@ -1162,6 +1207,24 @@ 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 && !member.IsCustom
&& SqlTools.IncludeParameter(map, member.CodeName, out _))
{
needsParameterLocal = true;
break;
}
}
}

foreach (var member in planMembers)
{
if (!member.IsMapped) continue;
Expand Down Expand Up @@ -1216,11 +1279,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;
}
Expand All @@ -1244,6 +1305,35 @@ 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,
// 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();

Expand Down Expand Up @@ -1311,6 +1401,12 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co
}
break;
case WriteArgsMode.Update:
if (member.IsExpandable || member.IsCustom)
{
// update is only reachable via command-cache reuse and batch, both of
// which are refused for self-binding members at parse
break;
}
if (member.IsDbString)
{
ctx.GeneratorContext.IncludeGenerationType(IncludedGeneration.DbStringHelpers);
Expand Down
17 changes: 14 additions & 3 deletions src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ 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 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 @@ -161,7 +164,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 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 @@ -171,6 +175,9 @@ private ParamMember(bool isMapped, bool isCancellation, bool isRowCount, string
DbName = dbName;
Direction = direction;
IsDbString = isDbString;
IsExpandable = isExpandable;
IsCustom = isCustom;
IsValueType = isValueType;
HasDbType = hasDbType;
DbTypeName = dbTypeName;
EffectiveSize = effectiveSize;
Expand All @@ -184,7 +191,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, false, false, null, null, false, null, null, "");
}
var dbType = member.GetDbType(out _);
var size = member.TryGetValue<int>("Size");
Expand All @@ -208,7 +215,8 @@ 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,
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 @@ -221,6 +229,9 @@ public bool Equals(ParamMember other) => IsMapped == other.IsMapped
&& string.Equals(DbName, other.DbName, StringComparison.Ordinal)
&& 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 @@ -574,6 +574,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 @@ -662,6 +689,8 @@ public DapperSpecialType DapperSpecialType
}
}) return DapperSpecialType.DbString;

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

return DapperSpecialType.None;
}
}
Expand Down
Loading
Loading