Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4e20b8c
Initial plan
Copilot Dec 10, 2025
486de5d
Add specific diagnostic for multi-map queries (DAP050)
Copilot Dec 10, 2025
18e68b7
Revert diagnostic-only approach, prepare for full multi-map implement…
Copilot Dec 10, 2025
36ca4c8
WIP: Add initial multi-map query support - runtime and code generatio…
Copilot Dec 10, 2025
e4a56fd
Refactor multi-map query handling and update related tests for improv…
devedse Dec 10, 2025
f676040
Refactor multi-map implementation to streamline SQL command generatio…
devedse Dec 10, 2025
ea90e13
Enhance type handling in DapperInterceptorGenerator and add AppendTyp…
devedse Dec 10, 2025
e7b55bc
Refactor Tokenize method in interceptors to handle available columns …
devedse Dec 10, 2025
a753a0a
Enhance Dapper.AOT Interceptors to Handle Unmapped Columns
devedse Dec 11, 2025
2502e74
Cleanup
devedse Dec 11, 2025
7b29d7c
Refactor code structure for improved readability and maintainability
devedse Dec 11, 2025
29210e8
Cleanup
devedse Dec 12, 2025
0234f3d
Fix split index usage in QueryBuffered method to correctly read secon…
devedse Dec 12, 2025
ca820c0
Fix Query method signature in DAP001 verifier to support additional g…
devedse Dec 12, 2025
818c774
Refactor FindSplits method to improve clarity and maintainability
devedse Dec 12, 2025
132b126
Refactor interceptors to improve token handling and eliminate unmappe…
devedse Dec 12, 2025
44ba3f4
Add tests for multi-map query support with varying arities
devedse Dec 13, 2025
52f5f8d
Merge pull request #2 from devedse/copilot/fix-support-for-spliton
devedse Dec 13, 2025
a475607
Implement feature X to enhance user experience and fix bug Y in module Z
devedse Dec 13, 2025
21180fb
Normalize split column names for improved comparison in multi-map que…
devedse Dec 13, 2025
a364724
Refactor split column normalization for case-insensitive comparison i…
devedse Dec 13, 2025
ec8f0cd
If the first field of a multimap thing is null, then the whole variab…
devedse Dec 14, 2025
8a52c2b
Enhance splitOn handling in multi-map queries to support wildcard and…
devedse Dec 14, 2025
58d215f
Implement special handling for enum types in value conversion to alig…
devedse Dec 14, 2025
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
2 changes: 2 additions & 0 deletions src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,8 @@ internal static Location SharedParseArgsAndFlags(in ParseState ctx, IInvocationO
case "startIndex":
case "length":
case "returnNullIfFirstMissing":
case "map": // multi-map function parameter
case "splitOn": // multi-map split column parameter
case "concreteType" when arg.Value is IDefaultValueOperation || (arg.ConstantValue.HasValue && arg.ConstantValue.Value is null):
// nothing to do
break;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using Dapper.Internal;
using Dapper.Internal.Roslyn;
using Microsoft.CodeAnalysis;
using System.Collections.Immutable;

namespace Dapper.CodeAnalysis;

public sealed partial class DapperInterceptorGenerator
{
static void WriteMultiMapImplementation(
CodeWriter sb,
IMethodSymbol method,
OperationFlags flags,
OperationFlags commandTypeMode,
ITypeSymbol? parameterType,
string map, bool cache,
in ImmutableArray<IParameterSymbol> methodParameters,
in CommandFactoryState factories,
in RowReaderState readers,
string? fixedSql,
AdditionalCommandState? additionalCommandState)
{
var typeArgs = method.TypeArguments;
var arity = typeArgs.Length;

sb.Append("return ");
if (flags.HasAll(OperationFlags.Async | OperationFlags.Query | OperationFlags.Buffered))
{
sb.Append("global::Dapper.DapperAotExtensions.AsEnumerableAsync(").Indent(false).NewLine();
}

// Create the Command<TArgs>
sb.Append("global::Dapper.DapperAotExtensions.Command(cnn, ").Append(Forward(methodParameters, "transaction")).Append(", ");
if (fixedSql is not null)
{
sb.AppendVerbatimLiteral(fixedSql).Append(", ");
}
else
{
sb.Append("sql, ");
}
if (commandTypeMode == 0)
{
if (HasParam(methodParameters, "command"))
{
sb.Append("command.GetValueOrDefault()");
}
else
{
sb.Append("default");
}
}
else
{
sb.Append("global::System.Data.CommandType.").Append(commandTypeMode.ToString());
}
sb.Append(", ").Append(Forward(methodParameters, "commandTimeout")).Append(HasParam(methodParameters, "commandTimeout") ? ".GetValueOrDefault()" : "").Append(", ");
if (flags.HasAny(OperationFlags.HasParameters))
{
var index = factories.GetIndex(parameterType!, map, cache, additionalCommandState, out var subIndex);
sb.Append("CommandFactory").Append(index).Append(".Instance").Append(subIndex);
}
else
{
sb.Append("DefaultCommandFactory");
}
sb.Append(").");

// Call the appropriate QueryBuffered/QueryBufferedAsync/QueryUnbuffered/QueryUnbufferedAsync method
bool isAsync = flags.HasAny(OperationFlags.Async);
bool isUnbuffered = flags.HasAny(OperationFlags.Unbuffered);

if (isUnbuffered)
{
sb.Append("QueryUnbuffered");
}
else
{
sb.Append("QueryBuffered");
}

if (isAsync)
{
sb.Append("Async");
}

// Add type parameters <T1, T2, ..., TReturn>
sb.Append("<");
for (int i = 0; i < arity; i++)
{
if (i > 0) sb.Append(", ");
sb.Append(typeArgs[i]);
}
sb.Append(">(");

// Add arguments: args, map, factory1, factory2, ..., splitOn, rowCountHint
WriteTypedArg(sb, parameterType).Append(", ");
sb.Append(Forward(methodParameters, "map")).Append(", ");

// Add row factories for each type (except the return type)
for (int i = 0; i < arity - 1; i++)
{
var typeArg = typeArgs[i];
sb.AppendReader(typeArg, readers, flags, additionalCommandState?.QueryColumns ?? default);
sb.Append(", ");
}

// Add splitOn parameter
if (HasParam(methodParameters, "splitOn"))
{
sb.Append(Forward(methodParameters, "splitOn"));
}
else
{
sb.Append("\"Id\"");
}

// Add rowCountHint if needed (only for buffered queries)
if (!isUnbuffered && flags.HasAny(OperationFlags.Query) && additionalCommandState is { HasRowCountHint: true })
{
if (additionalCommandState.RowCountHintMemberName is null)
{
sb.Append(", rowCountHint: ").Append(additionalCommandState.RowCountHint);
}
else
{
// member-based hint; add after args
sb.Append(", rowCountHint: ").Append("args.").Append(additionalCommandState.RowCountHintMemberName);
}
}

// Add cancellationToken for async
if (isAsync && HasParam(methodParameters, "cancellationToken"))
{
sb.Append(", cancellationToken: ").Append(Forward(methodParameters, "cancellationToken"));
}

if (flags.HasAll(OperationFlags.Async | OperationFlags.Query | OperationFlags.Buffered))
{
sb.Append(")").Outdent(false);
}
sb.Append(")");
sb.Append(";").NewLine();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,19 +152,6 @@ static void WriteSingleImplementation(
}
}
sb.Append(";").NewLine();

static CodeWriter WriteTypedArg(CodeWriter sb, ITypeSymbol? parameterType)
{
if (parameterType is null || parameterType.IsAnonymousType)
{
sb.Append("param");
}
else
{
sb.Append("(").Append(parameterType).Append(")param!");
}
return sb;
}
}

private static bool HasParam(ImmutableArray<IParameterSymbol> methodParameters, string name)
Expand All @@ -181,4 +168,17 @@ private static bool HasParam(ImmutableArray<IParameterSymbol> methodParameters,

private static string Forward(ImmutableArray<IParameterSymbol> methodParameters, string name)
=> HasParam(methodParameters, name) ? name : "default";

private static CodeWriter WriteTypedArg(CodeWriter sb, ITypeSymbol? parameterType)
{
if (parameterType is null || parameterType.IsAnonymousType)
{
sb.Append("param");
}
else
{
sb.Append("(").Append(parameterType).Append(")param!");
}
return sb;
}
}
45 changes: 34 additions & 11 deletions src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ internal void Generate(in GenerateState ctx)
{
WriteGetRowParser(sb, resultType, readers, grp.Key.Flags, grp.Key.AdditionalCommandState?.QueryColumns ?? default);
}
else if (flags.HasAny(OperationFlags.MultiMap))
{
WriteMultiMapImplementation(sb, method, flags, commandTypeMode, parameterType, grp.Key.ParameterMap, grp.Key.UniqueLocation is not null, methodParameters, factories, readers, fixedSql, additionalCommandState);
}
else if (!TryWriteMultiExecImplementation(sb, flags, commandTypeMode, parameterType, grp.Key.ParameterMap, grp.Key.UniqueLocation is not null, methodParameters, factories, fixedSql, additionalCommandState))
{
WriteSingleImplementation(sb, method, resultType, flags, commandTypeMode, parameterType, grp.Key.ParameterMap, grp.Key.UniqueLocation is not null, methodParameters, factories, readers, fixedSql, additionalCommandState);
Expand Down Expand Up @@ -818,7 +822,10 @@ void WriteTokenizeMethod()
sb.Append("public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span<int> tokens, int columnOffset)").Indent().NewLine();
if (queryColumns.IsDefault) // need to apply full map
{
sb.Append("for (int i = 0; i < tokens.Length; i++)").Indent().NewLine()
// Limit tokens to available columns starting from columnOffset
sb.Append("int availableColumns = reader.FieldCount - columnOffset;").NewLine()
.Append("int tokenCount = global::System.Math.Min(tokens.Length, availableColumns);").NewLine()
.Append("for (int i = 0; i < tokenCount; i++)").Indent().NewLine()
.Append("int token = -1;").NewLine()
.Append("var name = reader.GetName(columnOffset);").NewLine()
.Append("var type = reader.GetFieldType(columnOffset);").NewLine()
Expand Down Expand Up @@ -850,19 +857,26 @@ void WriteTokenizeMethod()
sb.Outdent().NewLine()
.Append("tokens[i] = token;").NewLine()
.Append("columnOffset++;").NewLine()
.Outdent().NewLine();
.Outdent().NewLine()
.Append("// Initialize remaining tokens to -1 (unmapped)").NewLine()
.Append("for (int i = tokenCount; i < tokens.Length; i++)").Indent().NewLine()
.Append("tokens[i] = -1;").Outdent().NewLine()
.Append("return tokenCount;").Outdent().NewLine();
}
else
{
sb.Append("global::System.Diagnostics.Debug.Assert(tokens.Length >= ").Append(queryColumns.Length).Append(""", "Query columns count mismatch");""").NewLine();
if (flags.HasAny(OperationFlags.StrictTypes))
{
sb.Append("// (no mapping applied for strict types and pre-defined columns)").NewLine();
sb.Append("return null;").Outdent().NewLine();
}
else
{
sb.Append("// pre-defined columns, but still needs type map").NewLine();
sb.Append("for (int i = 0; i < tokens.Length; i++)").Indent().NewLine()
sb.Append("int availableColumns = reader.FieldCount - columnOffset;").NewLine()
.Append("int tokenCount = global::System.Math.Min(tokens.Length, availableColumns);").NewLine()
.Append("for (int i = 0; i < tokenCount; i++)").Indent().NewLine()
.Append("var type = reader.GetFieldType(columnOffset);").NewLine()
.Append("tokens[i] = i switch").Indent().NewLine();
for (int i = 0; i < members.Length;i++)
Expand All @@ -874,18 +888,25 @@ void WriteTokenizeMethod()
.Append(" : ").Append(i + map.Members.Length).Append(",").NewLine();
}
}
sb.Append("_ => -1,").Outdent().Append(";").Outdent().NewLine();
sb.Append("_ => -1,").Outdent().Append(";").NewLine()
.Append("columnOffset++;").Outdent().NewLine()
.Append("// Initialize remaining tokens to -1 (unmapped)").NewLine()
.Append("for (int i = tokenCount; i < tokens.Length; i++)").Indent().NewLine()
.Append("tokens[i] = -1;").Outdent().NewLine()
.Append("return tokenCount;").Outdent().NewLine();
}
}

sb.Append("return null;").Outdent().NewLine();
}
void WriteReadMethod(in GenerateState context)
{
const string DeferredConstructionVariableName = "value";

sb.Append("public override ").Append(type).Append(" Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan<int> tokens, int columnOffset, object? state)").Indent().NewLine();

// For multi-map queries: if this is not the first entity (columnOffset > 0) and the first column is NULL,
// return default to handle LEFT JOINs where the related entity doesn't exist
sb.Append("if (columnOffset > 0 && reader.IsDBNull(columnOffset)) return default!;").NewLine();

int token = 0;
var deferredMethodArgumentsOrdered = new SortedList<int, string>();

Expand Down Expand Up @@ -934,13 +955,15 @@ void WriteReadMethod(in GenerateState context)
{
// no mapping involved - simple ordinal iteration
sb.Append("int lim = global::System.Math.Min(tokens.Length, ").Append(queryColumns.Length).Append(");").NewLine()
.Append("for (int token = 0; token < lim; token++) // query-columns predefined");
.Append("for (int token = 0; token < lim; token++) // query-columns predefined").Indent().NewLine();
}
else
{
sb.Append("foreach (var token in tokens)");
sb.Append("int tokenCount = state is int count ? count : tokens.Length;").NewLine()
.Append("for (int i = 0; i < tokenCount; i++)").Indent().NewLine()
.Append("var token = tokens[i];").NewLine();
}
sb.Indent().NewLine().Append("switch (token)").Indent().NewLine();
sb.Append("switch (token)").Indent().NewLine();

token = 0;
foreach (var member in members)
Expand Down Expand Up @@ -1009,7 +1032,7 @@ void WriteReadMethod(in GenerateState context)
if (useConstructorDeferred)
{
// `return new Type(member0, member1, member2, ...);`
sb.Append("return new ").Append(type).Append('(');
sb.Append("return new ").AppendTypeForNew(type).Append('(');
WriteDeferredMethodArgs();
sb.Append(')');
WriteDeferredInitialization();
Expand All @@ -1031,7 +1054,7 @@ void WriteReadMethod(in GenerateState context)
// Member1 = value1,
// Member2 = value2
// }
sb.Append("return new ").Append(type);
sb.Append("return new ").AppendTypeForNew(type);
WriteDeferredInitialization();
sb.Append(";").Outdent();
}
Expand Down
18 changes: 18 additions & 0 deletions src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,24 @@ public CodeWriter Append(ITypeSymbol? value, bool anonToTuple = false)
return this;
}

// Append type for use in 'new Type()' expressions, stripping nullable annotation
public CodeWriter AppendTypeForNew(ITypeSymbol? value)
{
if (value is null)
{ }
else if (value.IsAnonymousType)
{
Append(value.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat));
}
else
{
// Strip nullable annotation for object creation (can't do 'new Type?()')
var nonNullable = value.WithNullableAnnotation(NullableAnnotation.NotAnnotated);
Append(GetTypeName(nonNullable));
}
return this;
}

private void AppendAsValueTuple(ITypeSymbol value)
{
var members = value.GetMembers();
Expand Down
13 changes: 12 additions & 1 deletion src/Dapper.AOT.Analyzers/Internal/Inspection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1244,7 +1244,12 @@ public static bool IsDapperMethod(this IInvocationOperation operation, out Opera
{
case "Query":
flags |= OperationFlags.Query;
if (method.Arity > 1) flags |= OperationFlags.NotAotSupported;
if (method.Arity > 1)
{
flags |= OperationFlags.MultiMap;
// Multi-map is supported for 2-7 types (arity 3-8: T1, T2, TReturn through T1, T2, T3, T4, T5, T6, T7, TReturn)
if (method.Arity > 8) flags |= OperationFlags.NotAotSupported;
}
break;
case "QueryAsync":
case "QueryUnbufferedAsync":
Expand Down Expand Up @@ -1305,6 +1310,11 @@ public static bool TryGetConstantValue<T>(IOperation op, out T? value)
{
return typeArgs[0];
}
// For multi-map queries, the last type argument is the return type
if (typeArgs.Length > 1 && flags.HasAny(OperationFlags.MultiMap))
{
return typeArgs[typeArgs.Length - 1];
}
}
return null;
}
Expand Down Expand Up @@ -1597,5 +1607,6 @@ enum OperationFlags
QueryMultiple = 1 << 22,
GetRowParser = 1 << 23,
StrictTypes = 1 << 24,
MultiMap = 1 << 25, // multi-map queries (Query<T1, T2, TReturn>)
NotAotSupported = 1 << 31,
}
Loading