diff --git a/docs/rules/DAP053.md b/docs/rules/DAP053.md new file mode 100644 index 00000000..a8ca24fc --- /dev/null +++ b/docs/rules/DAP053.md @@ -0,0 +1,41 @@ +# DAP053 + +Type-handler registered at runtime + +`SqlMapper.AddTypeHandler` mutates a process-global registry when it executes. The generator +runs at compile time, so it cannot see that call - and generated code therefore binds the value +the way it would have without any handler at all: usually a raw `p.Value = ...`, which the +provider rejects ("No mapping exists from object type ..."), or a read that never reaches your +`Parse`. + +``` csharp +SqlMapper.AddTypeHandler(new LocalDateHandler()); // DAP053 +``` + +**The fix**: declare the handler instead, so the generator can bake the dispatch: + +``` csharp +[module: TypeHandler(typeof(LocalDate), typeof(LocalDateHandler))] +``` + +The declarative form is per-assembly rather than process-global, is deterministic (no +startup-ordering races), and shows up in review. An existing handler written against vanilla +Dapper (`SqlMapper.TypeHandler` / `SqlMapper.ITypeHandler`) can be named as-is: generated +code adapts it to Dapper.AOT's `IDbValueHandler`. + +A handler written for Dapper.AOT directly implements `IDbValueHandler`, usually by +inheriting `DbValueHandler`: + +``` csharp +public sealed class LocalDateHandler : DbValueHandler +{ + protected override void Configure(DbParameter parameter) => parameter.DbType = DbType.Date; + protected override void SetValueCore(DbParameter parameter, LocalDate value) + => parameter.Value = new DateTime(value.Year, value.Month, value.Day); + protected override LocalDate Parse(object? value) { /* ... */ } +} +``` + +Note the limit of this diagnostic: it only sees `AddTypeHandler` calls in the compilation being +built. A registration performed by a referenced library, or through a helper that hides the +call, cannot be reported - so its absence is not proof that generated code sees every handler. diff --git a/docs/rules/DAP054.md b/docs/rules/DAP054.md new file mode 100644 index 00000000..1ea1f0f2 --- /dev/null +++ b/docs/rules/DAP054.md @@ -0,0 +1,33 @@ +# DAP054 + +Type-handler cannot be used + +A `[TypeHandler(typeof(TValue), typeof(THandler))]` registration named a handler that generated +code cannot use, so the registration is **ignored** - and values of that type bind as if it were +not there, which is usually the raw `p.Value = ...` the handler existed to replace. + +The reasons, and what each means: + +| message | what to do | +| --- | --- | +| implements neither `IDbValueHandler` nor `SqlMapper.ITypeHandler` | inherit `DbValueHandler`, or name a vanilla Dapper handler (generated code adapts it) | +| handles '`X`', not '`Y`' | the handler and the registered value type disagree - fix whichever is wrong | +| it has no public parameterless constructor | generated code constructs the handler itself; give it one, or use a handler that needs no state | +| it is abstract / it is static | name a concrete, instantiable type | +| it is not accessible to generated code | make it `public` or `internal` (generated code lives in your own assembly) | +| the handled type is not accessible to generated code | same, for `TValue` | + +``` csharp +[module: TypeHandler(typeof(LocalDate), typeof(LocalDateHandler))] // DAP054 if the handler is wrong + +public sealed class LocalDateHandler : DbValueHandler // <-- the shape it wants +{ + protected override void SetValueCore(DbParameter parameter, LocalDate value) + => parameter.Value = new DateTime(value.Year, value.Month, value.Day); + protected override LocalDate Parse(object? value) { /* ... */ } +} +``` + +This is a warning rather than an error because the code still compiles and runs - it just binds +without the handler. It is reported precisely because that failure is otherwise silent, which is +the thing the declarative registration exists to remove. diff --git a/docs/rules/DAP055.md b/docs/rules/DAP055.md new file mode 100644 index 00000000..8834f5dd --- /dev/null +++ b/docs/rules/DAP055.md @@ -0,0 +1,23 @@ +# DAP055 + +Duplicate type-handler + +Two different handlers were registered for the same type. There is no correct way to choose +between them, so the **first registration wins** - module scope before assembly scope, source +order within each - and the later one is ignored. + +``` csharp +[module: TypeHandler(typeof(LocalDate), typeof(FirstHandler))] +[module: TypeHandler(typeof(LocalDate), typeof(SecondHandler))] // DAP055: SecondHandler is ignored +``` + +**The fix**: delete whichever registration is wrong. If both are wanted for different reasons - +say, one for reads and one for writes - that belongs in a single handler, since generated code +dispatches both directions through the same instance. + +Registering the *same* handler twice is not reported: it is redundant, but the outcome is +identical either way. + +A dropped duplicate is not also checked for usability, so a registration that is both duplicated +and unusable gets this one message rather than this plus DAP054 - the second registration is +ignored regardless of whether it would have worked. diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs index 342d636f..6a23878e 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.Diagnostics.cs @@ -25,6 +25,12 @@ public static readonly DiagnosticDescriptor DapperAotTupleParameter = LibraryInfo("DAP014", "Tuple-type parameter", "Tuple-type parameters are not currently supported"), UntypedParameter = LibraryInfo("DAP015", "Untyped parameter", "The parameter type could not be resolved"), GenericTypeParameter = LibraryInfo("DAP016", "Generic type parameter", "Generic type parameters ({0}) are not currently supported"), + DuplicateTypeHandler = LibraryWarning("DAP055", "Duplicate type-handler", + "Type '{0}' has more than one handler registered ('{1}' and '{2}'); '{2}' will be ignored"), + UnusableTypeHandler = LibraryWarning("DAP054", "Type-handler cannot be used", + "'{0}' cannot be used as a type-handler for '{1}' because {2}; the registration is ignored, and values of '{1}' bind as if it were not there"), + RuntimeTypeHandlerRegistration = LibraryWarning("DAP053", "Type-handler registered at runtime", + "SqlMapper.AddTypeHandler for '{0}' is invisible to Dapper.AOT, so generated code binds the value without it; declare it instead with [module: TypeHandler(typeof({0}), typeof({1}))]"), FeatureNeedsNewerDapper = LibraryInfo("DAP052", "Feature requires a newer Dapper", "Dapper.AOT support for {0} needs '{1}', which the referenced Dapper version does not expose; the call-site is left on vanilla Dapper (which will not work under native AOT) - update the Dapper package to enable this"), NestedInGenericType = LibraryWarning("DAP051", "Type is only generic by containment", "Type '{0}' is generic only because it is declared inside generic type '{1}'; if it does not need the enclosing type parameters, move it to non-generic scope"), NonPublicType = LibraryInfo("DAP017", "Non-accessible type", "Type '{0}' is not accessible; {1} types are not currently supported"), diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs index 182f2ba2..e626ad2b 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs @@ -68,6 +68,59 @@ private void OnDapperAotHit() } } } + /// + /// A [TypeHandler(...)] naming something generated code cannot use is otherwise + /// silently skipped - which is the exact failure mode the declarative form exists to + /// remove, so it has to be said out loud. + /// + private static void ReportTypeHandlerProblems(CompilationAnalysisContext ctx) + { + // module then assembly, in that order, so "the first one wins" means the same thing + // here as it does in the generator + var claimed = new Dictionary(StringComparer.Ordinal); + Report(ctx.Compilation.SourceModule.GetAttributes()); + Report(ctx.Compilation.Assembly.GetAttributes()); + + void Report(ImmutableArray attributes) + { + foreach (var attribute in attributes) + { + if (attribute.AttributeClass is not { Name: Types.TypeHandlerAttribute, Arity: 0 } + || !Inspection.IsDapperAttribute(attribute) + || attribute.ConstructorArguments.Length != 2) continue; + + if (attribute.ConstructorArguments[0].Value is not ITypeSymbol valueType + || attribute.ConstructorArguments[1].Value is not INamedTypeSymbol handlerType) continue; + + var location = attribute.ApplicationSyntaxReference is { } syntax + ? Location.Create(syntax.SyntaxTree, syntax.Span) : Location.None; + + var key = valueType.ToDisplayString(); + if (claimed.TryGetValue(key, out var incumbent)) + { + // an exact repeat is harmless - same handler, same outcome - but two + // *different* handlers for one type has no correct resolution, so say + // which one is being dropped + if (!SymbolEqualityComparer.Default.Equals(incumbent, handlerType)) + { + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.DuplicateTypeHandler, location, + Display(valueType), Display(incumbent), Display(handlerType))); + } + continue; // it is ignored either way; do not also grade it + } + claimed.Add(key, handlerType); + + if (DapperInterceptorGenerator.ClassifyTypeHandler(handlerType, valueType, ctx.Compilation.Assembly, out var problem) is not null + || problem is null) continue; + + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.UnusableTypeHandler, location, + Display(handlerType), Display(valueType), problem)); + } + } + + static string Display(ITypeSymbol type) => type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + } + private void OnDapperAotMiss(Location location) { if (Thread.VolatileRead(ref _dapperHits) == 0 // fast short-circuit if we know we're all good @@ -88,6 +141,7 @@ internal void OnCompilationEndAction(CompilationAnalysisContext ctx) { try { + ReportTypeHandlerProblems(ctx); lock (_missedOpportunities) { var count = _missedOpportunities.Count; @@ -121,6 +175,7 @@ public void OnOperation(OperationAnalysisContext ctx) switch (ctx.Operation.Kind) { case OperationKind.Invocation when ctx.Operation is IInvocationOperation invoke: + DetectRuntimeTypeHandlerRegistration(ctx, invoke); int index = 0; foreach (var p in invoke.TargetMethod.Parameters) { @@ -184,6 +239,59 @@ public void OnOperation(OperationAnalysisContext ctx) } } + /// + /// A runtime SqlMapper.AddTypeHandler registration cannot be seen by the generator, + /// so generated code silently ignores it; point at the declarative form instead. Only what + /// is registered in *this* compilation is visible here - a registration made by a + /// referenced library is not - so this reduces the silent-wrongness surface, it does not + /// close it. + /// + private static void DetectRuntimeTypeHandlerRegistration(in OperationAnalysisContext ctx, IInvocationOperation invoke) + { + var method = invoke.TargetMethod; + if (method is not { Name: "AddTypeHandler", IsStatic: true, ContainingType: { Name: "SqlMapper", ContainingNamespace: { Name: "Dapper", ContainingNamespace.IsGlobalNamespace: true } } }) return; + + var parseState = new ParseState(ctx); + if (!IsEnabled(in parseState, invoke, Types.DapperAotAttribute, out _)) return; // vanilla-only code is fine as-is + + // AddTypeHandler(TypeHandler) or AddTypeHandler(Type, ITypeHandler) + ITypeSymbol? valueType = method.TypeArguments.Length == 1 ? method.TypeArguments[0] : null; + if (valueType is null) + { + foreach (var arg in invoke.Arguments) + { + if (arg.Value is ITypeOfOperation typeOf) { valueType = typeOf.TypeOperand; break; } + } + } + if (valueType is null) return; // cannot name it; saying nothing beats guessing + + foreach (var handler in DapperInterceptorGenerator.GetTypeHandlers(ctx.Compilation)) + { + if (string.Equals(handler.ValueTypeName, CodeWriter.GetAppendTypeName(valueType), StringComparison.Ordinal)) + { + return; // already declared; the runtime call is redundant but harmless + } + } + + // name the *concrete* handler being registered, not the parameter's declared type, + // so the suggested attribute can be pasted as-is + var handlerType = "TheHandler"; + foreach (var arg in invoke.Arguments) + { + var value = arg.Value; + while (value is IConversionOperation conversion) value = conversion.Operand; + if (value is not ITypeOfOperation && value.Type is INamedTypeSymbol named) + { + handlerType = Display(named); + break; + } + } + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.RuntimeTypeHandlerRegistration, + invoke.Syntax.GetLocation(), Display(valueType), handlerType)); + + static string Display(ITypeSymbol type) => type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + } + private void ValidateDapperMethod(in OperationAnalysisContext ctx, IOperation sqlSource, OperationFlags flags) { Action onDiagnostic = ctx.ReportDiagnostic; diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs index 1139de5c..d402bad0 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs @@ -284,7 +284,118 @@ internal static InterceptorEnvironment CreateEnvironment(Compilation compilation baseCommandFactoryName: baseFactory, baseFactoryCanConstruct: canConstruct, specialCommandTypes: special, - systemObjectPlan: ParamPlan.Create(compilation.GetSpecialType(SpecialType.System_Object))!); + systemObjectPlan: ParamPlan.Create(compilation.GetSpecialType(SpecialType.System_Object))!, + typeHandlers: GetTypeHandlers(compilation)); + } + + /// + /// Collect [TypeHandler(typeof(TValue), typeof(THandler))] registrations declared at + /// module or assembly scope, classifying each handler as native (IDbValueHandler<T>) + /// or vanilla (Dapper's SqlMapper.ITypeHandler, which generated code adapts). + /// + internal static EquatableArray GetTypeHandlers(Compilation compilation) + { + List? found = null; + Add(compilation.SourceModule.GetAttributes()); + Add(compilation.Assembly.GetAttributes()); + return found is null ? default : new(found.ToArray()); + + void Add(ImmutableArray attributes) + { + foreach (var attribute in attributes) + { + if (attribute.AttributeClass is not { Name: Types.TypeHandlerAttribute, Arity: 0 } + || !Inspection.IsDapperAttribute(attribute) + || attribute.ConstructorArguments.Length != 2) continue; // the one-arg form is member-scoped + + if (attribute.ConstructorArguments[0].Value is not ITypeSymbol valueType + || attribute.ConstructorArguments[1].Value is not INamedTypeSymbol handlerType) continue; + + var kind = ClassifyTypeHandler(handlerType, valueType, compilation.Assembly, out _); + if (kind is null) continue; // not a shape generated code can use; DAP054 says why + + var valueTypeName = CodeWriter.GetAppendTypeName(valueType); + found ??= new(); + // first registration wins, deterministically; DAP055 reports the ones dropped + if (TypeHandlerRegistration.TryFind(new(found.ToArray()), valueTypeName, out _)) continue; + found.Add(new TypeHandlerRegistration( + valueTypeName, + CodeWriter.GetAppendTypeName(handlerType), + kind.GetValueOrDefault())); + } + } + } + + /// + /// false for a native IDbValueHandler<TValue>, true for a vanilla + /// Dapper handler needing the adapter, null when the type is neither (or cannot be + /// constructed by generated code). + /// + internal static bool? ClassifyTypeHandler(INamedTypeSymbol handlerType, ITypeSymbol valueType, IAssemblySymbol? consumer, out string? problem) + { + problem = null; + if (handlerType.IsStatic) { problem = "it is static"; return null; } + if (handlerType.IsAbstract) { problem = "it is abstract"; return null; } + if (handlerType.IsGenericType && handlerType.IsUnboundGenericType) + { + problem = "it is an unbound generic type"; + return null; + } + // generated code lives in the consumer's assembly, so that is where accessibility is judged + if (!Inspection.IsPublicOrAssemblyLocal(handlerType, consumer, out _)) + { + problem = "it is not accessible to generated code"; + return null; + } + if (!Inspection.IsPublicOrAssemblyLocal(valueType, consumer, out _)) + { + problem = "the handled type is not accessible to generated code"; + return null; + } + var hasPublicParameterlessCtor = false; + foreach (var ctor in handlerType.InstanceConstructors) + { + if (ctor.Parameters.IsEmpty) + { + hasPublicParameterlessCtor = ctor.DeclaredAccessibility == Accessibility.Public; + break; + } + } + if (!hasPublicParameterlessCtor) + { + problem = "it has no public parameterless constructor"; + return null; + } + + foreach (var iface in handlerType.AllInterfaces) + { + // Dapper.IDbValueHandler, where T is the registered value type + if (iface is { Name: "IDbValueHandler", Arity: 1, ContainingNamespace: { Name: "Dapper", ContainingNamespace.IsGlobalNamespace: true } } + && SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], valueType)) + { + return false; + } + } + foreach (var iface in handlerType.AllInterfaces) + { + // Dapper.SqlMapper.ITypeHandler: the consumer's own Dapper (or Dapper.StrongName), + // which this generator can see even though the runtime library deliberately cannot + if (iface is { Name: "ITypeHandler", Arity: 0, ContainingType: { Name: "SqlMapper", ContainingNamespace: { Name: "Dapper", ContainingNamespace.IsGlobalNamespace: true } } }) + { + return true; + } + } + // a handler for the *wrong* value type is the likeliest mistake here, so say which + foreach (var iface in handlerType.AllInterfaces) + { + if (iface is { Name: "IDbValueHandler", Arity: 1, ContainingNamespace: { Name: "Dapper", ContainingNamespace.IsGlobalNamespace: true } }) + { + problem = $"it handles '{iface.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)}', not '{valueType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)}'"; + return null; + } + } + problem = "it implements neither IDbValueHandler nor SqlMapper.ITypeHandler"; + return null; } private static string? GetCommandFactory(Compilation compilation, out bool canConstruct) @@ -579,7 +690,7 @@ internal void Generate(in GenerateState ctx) foreach (var tuple in readers) { - WriteRowFactory(sb, tuple.Plan, tuple.Index, tuple.Flags); + WriteRowFactory(in ctx, sb, tuple.Plan, tuple.Index, tuple.Flags); } foreach (var tuple in factories) @@ -587,6 +698,9 @@ internal void Generate(in GenerateState ctx) WriteCommandFactory(ctx, baseCommandFactory, sb, tuple.Plan, tuple.Index, tuple.Map, tuple.CacheCount, tuple.AdditionalCommandState); } + // last, because only now do we know which registrations the emitted code actually reached + WriteTypeHandlers(in ctx, sb); + sb.Outdent().Outdent(); // ends our generated file-scoped class and the namespace var preGeneratedCodeWriter = new PreGeneratedCodeWriter(sb, env.HasInterceptsLocationAttribute); @@ -598,6 +712,38 @@ internal void Generate(in GenerateState ctx) methodIndex, factories.Count(), readers.Count())); } + /// + /// Emit one static per [TypeHandler(...)] registration that emitted code reached. A + /// vanilla Dapper handler is wrapped in the generated adapter: this library cannot reference + /// Dapper (the consumer may be using Dapper or Dapper.StrongName, and referencing either + /// would load both and split the registry), but generated code compiles against whichever the + /// consumer has, so the adapter can bridge the two contracts. + /// + private static void WriteTypeHandlers(in GenerateState ctx, CodeWriter sb) + { + var used = ctx.GeneratorContext.UsedTypeHandlers; + if (used.Count == 0) return; + + var handlers = ctx.Environment.TypeHandlers; + sb.NewLine(); + foreach (var index in used) + { + var handler = handlers[index]; + sb.Append("private static readonly global::Dapper.IDbValueHandler<").Append(handler.ValueTypeName) + .Append("> TypeHandler").Append(index).Append(" = "); + if (handler.IsVanilla) + { + ctx.GeneratorContext.IncludeGenerationType(IncludedGeneration.VanillaTypeHandlerAdapter); + sb.Append("new global::Dapper.Aot.Generated.VanillaTypeHandler<").Append(handler.ValueTypeName) + .Append(">(new ").Append(handler.HandlerTypeName).Append("());").NewLine(); + } + else + { + sb.Append("new ").Append(handler.HandlerTypeName).Append("();").NewLine(); + } + } + } + private static void WriteGetRowParser(CodeWriter sb, RowPlan? resultPlan, in RowReaderState readers, OperationFlags flags) { sb.Append("return ").AppendReader(resultPlan, readers, flags) @@ -845,10 +991,34 @@ static bool IsReserved(string name) } } - private static void WriteRowFactory(CodeWriter sb, RowPlan plan, int index, OperationFlags flags) + private static void WriteRowFactory(in GenerateState ctx, CodeWriter sb, RowPlan plan, int index, OperationFlags flags) { var members = plan.Members; var queryColumns = plan.QueryColumns; + var typeHandlers = ctx.Environment.TypeHandlers; + var generatorContext = ctx.GeneratorContext; + + // a registered handler owns the read for its type, so the column's own type is not consulted + string? HandlerFor(in RowMember member) + => TypeHandlerRegistration.TryFind(typeHandlers, member.NonNullTypeName, out var handlerIndex) + ? generatorContext.UseTypeHandler(handlerIndex) : null; + + // when any member reads through a handler, the handler's own per-column token travels in + // the factory's `state` channel: one array per query, indexed like `tokens` + var handlerTokenMembers = new List<(int Token, string Handler)>(); + { + int probe = 0; + foreach (var member in members) + { + if (member.IsMapped && HandlerFor(in member) is string handler) + { + handlerTokenMembers.Add((probe, handler)); + } + probe++; + } + } + var anyHandler = handlerTokenMembers.Count != 0; + const string HandlerTokens = "handlerTokens"; if (members.IsEmpty && !plan.UseConstructor && !plan.UseFactoryMethod) { @@ -909,6 +1079,11 @@ void WriteRowFactoryFooter() void WriteTokenizeMethod() { sb.Append("public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span tokens, int columnOffset)").Indent().NewLine(); + if (anyHandler) + { + sb.Append("var ").Append(HandlerTokens).Append(" = new int[tokens.Length];").NewLine() + .Append("var handlerOffset = columnOffset; // the loop below advances columnOffset").NewLine(); + } if (queryColumns.IsDefault) // need to apply full map { sb.Append("for (int i = 0; i < tokens.Length; i++)").Indent().NewLine() @@ -930,6 +1105,10 @@ void WriteTokenizeMethod() { sb.Append("token = ").Append(token).Append(";").Append(token == 0 ? " // note: strict types" : ""); } + else if (HandlerFor(in member) is not null) + { + sb.Append("token = ").Append(token + plan.TotalMemberCount).Append("; // type-handler: the handler decides"); + } else { sb.Append("token = type == typeof(").Append(member.TypeOfName).Append(") ? ").Append(token) @@ -963,14 +1142,48 @@ void WriteTokenizeMethod() var member = members[i]; if (member.IsMapped) { - sb.Append(i).Append(" => type == typeof(").Append(member.TypeOfName).Append(") ? ").Append(i) - .Append(" : ").Append(i + plan.TotalMemberCount).Append(",").NewLine(); + if (HandlerFor(in member) is not null) + { + sb.Append(i).Append(" => ").Append(i + plan.TotalMemberCount).Append(", // type-handler").NewLine(); + } + else + { + sb.Append(i).Append(" => type == typeof(").Append(member.TypeOfName).Append(") ? ").Append(i) + .Append(" : ").Append(i + plan.TotalMemberCount).Append(",").NewLine(); + } } } sb.Append("_ => -1,").Outdent().Append(";").Outdent().NewLine(); } } + if (anyHandler) + { + // a second pass, so this works the same whichever shape the mapping loop took; + // it runs once per query, not once per row + sb.Append("for (int i = 0; i < tokens.Length; i++)").Indent().NewLine() + .Append("switch (tokens[i])").Indent().NewLine(); + foreach (var group in handlerTokenMembers.GroupBy(static x => x.Handler, StringComparer.Ordinal)) + { + var first = true; + foreach (var (memberToken, _) in group) + { + if (!first) sb.Append(" "); + first = false; + sb.Append("case ").Append(memberToken).Append(":"); + if (!flags.HasAny(OperationFlags.StrictTypes)) + { + sb.Append(" case ").Append(memberToken + plan.TotalMemberCount).Append(":"); + } + } + sb.Indent(false).NewLine() + .Append(HandlerTokens).Append("[i] = ").Append(group.Key).Append(".Tokenize(reader, handlerOffset + i);").NewLine() + .Append("break;").Outdent(false).NewLine(); + } + sb.Outdent().NewLine().Outdent().NewLine() + .Append("return ").Append(HandlerTokens).Append(";").Outdent().NewLine(); + return; + } sb.Append("return null;").Outdent().NewLine(); } void WriteReadMethod() @@ -1022,17 +1235,30 @@ void WriteReadMethod() sb.Append(plan.NonNullTypeName).Append(" result = new();").NewLine(); } + var tokenIndex = "token"; // the ordinal within this row's token span, for handler lookups + if (anyHandler) + { + sb.Append("var ").Append(HandlerTokens).Append(" = (int[])state!;").NewLine(); + } if (!queryColumns.IsDefault && flags.HasAny(OperationFlags.StrictTypes)) { // 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"); + sb.Indent().NewLine().Append("switch (token)").Indent().NewLine(); + } + else if (anyHandler) + { + // indexed rather than foreach: a handler needs its token, which is found by position + tokenIndex = "i"; + sb.Append("for (int i = 0; i < tokens.Length; i++)") + .Indent().NewLine().Append("switch (tokens[i])").Indent().NewLine(); } else { sb.Append("foreach (var token in tokens)"); + sb.Indent().NewLine().Append("switch (token)").Indent().NewLine(); } - sb.Indent().NewLine().Append("switch (token)").Indent().NewLine(); token = 0; foreach (var member in members) @@ -1048,7 +1274,13 @@ void WriteReadMethod() sb.Append(" = "); sb.Append(nullCheck); - if (member.ReaderMethod is null) + var readHandler = HandlerFor(in member); + if (readHandler is not null) + { + sb.Append(readHandler).Append(".Parse(reader, columnOffset, ") + .Append(HandlerTokens).Append("[").Append(tokenIndex).Append("]);"); + } + else if (member.ReaderMethod is null) { sb.Append("reader.GetFieldValue<").Append(member.TypeName).Append(">(columnOffset);"); } @@ -1069,11 +1301,17 @@ void WriteReadMethod() if (useDeferredConstruction) sb.Append(DeferredConstructionVariableName).Append(token); else sb.Append("result.").Append(member.CodeName); - sb.Append(" = ") - .Append(nullCheck) - .Append("GetValue<") - .Append(member.NonNullTypeName).Append(">(reader, columnOffset);").NewLine() - .Append("break;").NewLine().Outdent(false); + sb.Append(" = ").Append(nullCheck); + if (readHandler is not null) + { + sb.Append(readHandler).Append(".Parse(reader, columnOffset, ") + .Append(HandlerTokens).Append("[").Append(tokenIndex).Append("]);").NewLine(); + } + else + { + sb.Append("GetValue<").Append(member.NonNullTypeName).Append(">(reader, columnOffset);").NewLine(); + } + sb.Append("break;").NewLine().Outdent(false); } } token++; @@ -1302,6 +1540,11 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co } sb.Append("if (Include(sql, commandType, ").AppendVerbatimLiteral(member.DbName).Append("))").Indent().NewLine(); } + // a registered [TypeHandler(...)] replaces the value conversion on every path + var hasHandler = TypeHandlerRegistration.TryFind(ctx.Environment.TypeHandlers, member.NonNullTypeName, out var handlerIndex) + && !member.IsCustom && !member.IsExpandable && !member.IsDbString; + var handler = hasHandler ? ctx.GeneratorContext.UseTypeHandler(handlerIndex) : ""; + switch (mode) { case WriteArgsMode.Add: @@ -1376,7 +1619,14 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co { case ParameterDirection.Input: case ParameterDirection.InputOutput: - if (useSetValueWithDefaultSize) + if (hasHandler) + { + // the handler owns the conversion *and* any type/size it needs, so + // the command's parameter shape is no longer statically known + flags &= ~WriteArgsFlags.CanPrepare; + WriteTypeHandlerSet(sb, in member, handler, "p", source); + } + else if (useSetValueWithDefaultSize) { sb.Append("SetValueWithDefaultSize(p, ").Append(source).Append(".").Append(member.CodeName).Append(");").NewLine(); } @@ -1419,6 +1669,12 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co break; } + if (hasHandler && direction is ParameterDirection.Input or ParameterDirection.InputOutput) + { + // in-place update of a recycled command: same dispatch, same parameter + WriteTypeHandlerSet(sb, in member, handler, AppendParameterAccessor(member.DbName, parameterIndex, flags), source); + break; + } sb.Append("ps["); if ((flags & WriteArgsFlags.NeedsTest) != 0) sb.AppendVerbatimLiteral(member.DbName); else sb.Append(parameterIndex); @@ -1436,6 +1692,12 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co } break; case WriteArgsMode.PostProcess: + if (hasHandler) + { + sb.Append(source).Append(".").Append(member.CodeName).Append(" = ").Append(handler) + .Append(".Parse(").Append(AppendParameterAccessor(member.DbName, parameterIndex, flags)).Append(");").NewLine(); + break; + } // we already eliminated args that we don't need to look at sb.Append(source).Append(".").Append(member.CodeName).Append(" = Parse<") .Append(member.TypeName).Append(">(ps["); @@ -1453,6 +1715,30 @@ private static void WriteArgs(in GenerateState ctx, ParamPlan? parameterType, Co } } + /// The ps[...] accessor for a parameter, by index or (when the parameter set is conditional) by name. + private static string AppendParameterAccessor(string dbName, int parameterIndex, WriteArgsFlags flags) + => (flags & WriteArgsFlags.NeedsTest) != 0 + ? "ps[" + SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(dbName)).ToFullString() + "]" + : "ps[" + parameterIndex.ToString(CultureInfo.InvariantCulture) + "]"; + + /// + /// Bind a value through its registered type-handler; null goes to SetNullValue so that a + /// handler over a struct is never handed a null it cannot express. + /// + private static void WriteTypeHandlerSet(CodeWriter sb, in ParamMember member, string handler, string target, string source) + { + if (member.IsValueType && !member.IsNullableValueType) + { + sb.Append(handler).Append(".SetValue(").Append(target).Append(", ").Append(source).Append(".").Append(member.CodeName).Append(");").NewLine(); + return; + } + sb.Append("if (").Append(source).Append(".").Append(member.CodeName).Append(" is null) ") + .Append(handler).Append(".SetNullValue(").Append(target).Append(");").NewLine() + .Append("else ").Append(handler).Append(".SetValue(").Append(target).Append(", ") + .Append(source).Append(".").Append(member.CodeName) + .Append(member.IsNullableValueType ? ".GetValueOrDefault()" : "").Append(");").NewLine(); + } + static void AppendDbParameterSetting(CodeWriter sb, string memberName, int? value) { if (value is not null) diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/GeneratorContext.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/GeneratorContext.cs index db8aa9fd..b15f61b5 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/GeneratorContext.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/GeneratorContext.cs @@ -1,4 +1,6 @@ -namespace Dapper.CodeAnalysis +using System.Collections.Generic; + +namespace Dapper.CodeAnalysis { /// /// Contains data about current generation run. @@ -16,6 +18,21 @@ public GeneratorContext() IncludedGenerationTypes = IncludedGeneration.InterceptsLocationAttribute; } + /// + /// The type-handler registrations actually reached by emitted code; only these get a + /// static, so unused registrations cost nothing (and raise no unused-field warning). + /// + public SortedSet UsedTypeHandlers { get; } = new(); + + /// + /// Note that a registration is in use, and yield the name of its static. + /// + public string UseTypeHandler(int index) + { + UsedTypeHandlers.Add(index); + return "TypeHandler" + index.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + /// /// Adds another generation type to the list of already included types. /// diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/InterceptorEnvironment.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/InterceptorEnvironment.cs index cf97fa0a..e2d4f480 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/InterceptorEnvironment.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/InterceptorEnvironment.cs @@ -16,10 +16,12 @@ internal sealed class InterceptorEnvironment : IEquatable SpecialCommandTypes { get; } // providers needing per-command setup public ParamPlan SystemObjectPlan { get; } // the parameterless command-factory fallback + public EquatableArray TypeHandlers { get; } // [TypeHandler(...)] at module/assembly level public InterceptorEnvironment(bool allowUnsafe, string? assemblyName, bool hasInterceptsLocationAttribute, bool needsCommandPrep, string? baseCommandFactoryName, bool baseFactoryCanConstruct, - in EquatableArray specialCommandTypes, ParamPlan systemObjectPlan) + in EquatableArray specialCommandTypes, ParamPlan systemObjectPlan, + in EquatableArray typeHandlers) { AllowUnsafe = allowUnsafe; AssemblyName = assemblyName; @@ -29,6 +31,7 @@ public InterceptorEnvironment(bool allowUnsafe, string? assemblyName, bool hasIn BaseFactoryCanConstruct = baseFactoryCanConstruct; SpecialCommandTypes = specialCommandTypes; SystemObjectPlan = systemObjectPlan; + TypeHandlers = typeHandlers; } public bool Equals(InterceptorEnvironment? other) => other is not null @@ -39,7 +42,8 @@ public bool Equals(InterceptorEnvironment? other) => other is not null && string.Equals(BaseCommandFactoryName, other.BaseCommandFactoryName, StringComparison.Ordinal) && BaseFactoryCanConstruct == other.BaseFactoryCanConstruct && SpecialCommandTypes.Equals(other.SpecialCommandTypes) - && SystemObjectPlan.Equals(other.SystemObjectPlan); + && SystemObjectPlan.Equals(other.SystemObjectPlan) + && TypeHandlers.Equals(other.TypeHandlers); public override bool Equals(object? obj) => Equals(obj as InterceptorEnvironment); public override int GetHashCode() diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs index 55eaf640..79b2d988 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/ParamPlan.cs @@ -162,11 +162,14 @@ public override int GetHashCode() public byte? Precision { get; } public byte? Scale { get; } public string TypeName { get; } // emitted (Append) form, for Parse in post-process + public string NonNullTypeName { get; } // MakeNonNullable form: the type-handler match key + public bool IsNullableValueType { get; } // Nullable: the handler takes the T private ParamMember(bool isMapped, bool isCancellation, bool isRowCount, string codeName, string dbName, ParameterDirection direction, bool isDbString, bool isExpandable, bool isCustom, bool isValueType, bool hasDbType, string? dbTypeName, int? effectiveSize, - bool useSetValueWithDefaultSize, byte? precision, byte? scale, string typeName) + bool useSetValueWithDefaultSize, byte? precision, byte? scale, string typeName, + string nonNullTypeName, bool isNullableValueType) { IsMapped = isMapped; IsCancellation = isCancellation; @@ -185,13 +188,15 @@ private ParamMember(bool isMapped, bool isCancellation, bool isRowCount, string Precision = precision; Scale = scale; TypeName = typeName; + NonNullTypeName = nonNullTypeName; + IsNullableValueType = isNullableValueType; } public static ParamMember Create(in ElementMember member) { if (!member.IsMapped) { - return new(false, false, false, "", "", default, false, false, false, false, false, null, null, false, null, null, ""); + return new(false, false, false, "", "", default, false, false, false, false, false, null, null, false, null, null, "", "", false); } var dbType = member.GetDbType(out _); var size = member.TryGetValue("Size"); @@ -219,7 +224,9 @@ public static ParamMember Create(in ElementMember member) 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!)); + CodeWriter.GetAppendTypeName(member.CodeType!), + CodeWriter.GetAppendTypeName(Inspection.MakeNonNullable(member.CodeType!)), + member.CodeType!.IsValueType && member.CodeType is INamedTypeSymbol { IsGenericType: true, ConstructedFrom.SpecialType: SpecialType.System_Nullable_T }); } public bool Equals(ParamMember other) => IsMapped == other.IsMapped @@ -238,7 +245,9 @@ public bool Equals(ParamMember other) => IsMapped == other.IsMapped && UseSetValueWithDefaultSize == other.UseSetValueWithDefaultSize && Precision == other.Precision && Scale == other.Scale - && string.Equals(TypeName, other.TypeName, StringComparison.Ordinal); + && string.Equals(TypeName, other.TypeName, StringComparison.Ordinal) + && string.Equals(NonNullTypeName, other.NonNullTypeName, StringComparison.Ordinal) + && IsNullableValueType == other.IsNullableValueType; public override bool Equals(object? obj) => obj is ParamMember other && Equals(other); public override int GetHashCode() => IsMapped ? StringComparer.Ordinal.GetHashCode(CodeName) : 0; diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/TypeHandlerRegistration.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/TypeHandlerRegistration.cs new file mode 100644 index 00000000..32f834d2 --- /dev/null +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/TypeHandlerRegistration.cs @@ -0,0 +1,58 @@ +using System; + +namespace Dapper.CodeAnalysis.Model; + +/// +/// A [TypeHandler(typeof(TValue), typeof(THandler))] registration, fully projected at +/// parse time (see the model shape test: no symbols may be cached). +/// +internal readonly struct TypeHandlerRegistration : IEquatable +{ + /// The handled type, non-nullable, in emitted (Append) form; the match key. + public string ValueTypeName { get; } + + /// The handler type, in emitted (Append) form. + public string HandlerTypeName { get; } + + /// + /// The handler is a vanilla Dapper SqlMapper.ITypeHandler rather than an + /// IDbValueHandler<T>, so generated code wraps it in the adapter shim. + /// + public bool IsVanilla { get; } + + public TypeHandlerRegistration(string valueTypeName, string handlerTypeName, bool isVanilla) + { + ValueTypeName = valueTypeName; + HandlerTypeName = handlerTypeName; + IsVanilla = isVanilla; + } + + /// + /// Find the handler registered for (emitted form, nullability + /// stripped by the caller), returning the index used to name the emitted static. + /// + public static bool TryFind(in EquatableArray handlers, string? typeName, out int index) + { + if (!string.IsNullOrEmpty(typeName) && !handlers.IsEmpty) + { + for (int i = 0; i < handlers.Length; i++) + { + if (string.Equals(handlers[i].ValueTypeName, typeName, StringComparison.Ordinal)) + { + index = i; + return true; + } + } + } + index = -1; + return false; + } + + public bool Equals(TypeHandlerRegistration other) + => string.Equals(ValueTypeName, other.ValueTypeName, StringComparison.Ordinal) + && string.Equals(HandlerTypeName, other.HandlerTypeName, StringComparison.Ordinal) + && IsVanilla == other.IsVanilla; + + public override bool Equals(object? obj) => obj is TypeHandlerRegistration other && Equals(other); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(ValueTypeName); +} diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Writers/PreGeneratedCodeWriter.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Writers/PreGeneratedCodeWriter.cs index 8de835cf..835470a2 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Writers/PreGeneratedCodeWriter.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Writers/PreGeneratedCodeWriter.cs @@ -60,6 +60,11 @@ public void Write(IncludedGeneration includedGenerations) { _codeWriter.NewLine().Append(Resources.ReadString("Dapper.InGeneration.DapperHelpers.cs")); } + + if (includedGenerations.HasAny(IncludedGeneration.VanillaTypeHandlerAdapter)) + { + _codeWriter.NewLine().Append(Resources.ReadString("Dapper.InGeneration.VanillaTypeHandler.cs")); + } } void WriteInterceptsLocationAttribute() diff --git a/src/Dapper.AOT.Analyzers/InGeneration/VanillaTypeHandler.cs b/src/Dapper.AOT.Analyzers/InGeneration/VanillaTypeHandler.cs new file mode 100644 index 00000000..9e05efe2 --- /dev/null +++ b/src/Dapper.AOT.Analyzers/InGeneration/VanillaTypeHandler.cs @@ -0,0 +1,41 @@ +namespace Dapper.Aot.Generated +{ + /// + /// Adapts a vanilla Dapper type-handler (SqlMapper.ITypeHandler) to Dapper.AOT's + /// IDbValueHandler{T}. + /// + /// + /// The runtime library cannot reference Dapper: a consumer may be using Dapper or + /// Dapper.StrongName, and referencing either would load both and split the handler registry. + /// Generated code has no such problem - it compiles against whichever one the consumer + /// actually references - so the shim is emitted here rather than shipped. + /// +#if !DAPPERAOT_INTERNAL + file +#endif + sealed class VanillaTypeHandler : global::Dapper.IDbValueHandler + { + private readonly global::Dapper.SqlMapper.ITypeHandler _inner; + public VanillaTypeHandler(global::Dapper.SqlMapper.ITypeHandler inner) => _inner = inner; + + public void SetValue(global::System.Data.Common.DbParameter parameter, T value) + // vanilla's handlers special-case DBNull and can fault on a raw null (the struct cast + // in TypeHandler's explicit interface implementation); vanilla coalesces first, so + // we do too + => _inner.SetValue(parameter, value is null ? (object)global::System.DBNull.Value : value); + + public void SetNullValue(global::System.Data.Common.DbParameter parameter) + => _inner.SetValue(parameter, global::System.DBNull.Value); + + public T Parse(global::System.Data.Common.DbParameter parameter) + => Convert(parameter.Value); + + public int Tokenize(global::System.Data.Common.DbDataReader reader, int columnOffset) => 0; + + public T Parse(global::System.Data.Common.DbDataReader reader, int ordinal, int token) + => Convert(reader.GetValue(ordinal)); + + private T Convert(object? value) + => value is null or global::System.DBNull ? default! : (T)_inner.Parse(typeof(T), value)!; + } +} diff --git a/src/Dapper.AOT.Analyzers/IncludedGeneration.cs b/src/Dapper.AOT.Analyzers/IncludedGeneration.cs index dc540e71..492599c4 100644 --- a/src/Dapper.AOT.Analyzers/IncludedGeneration.cs +++ b/src/Dapper.AOT.Analyzers/IncludedGeneration.cs @@ -8,5 +8,6 @@ internal enum IncludedGeneration None = 0, InterceptsLocationAttribute = 1 << 0, DbStringHelpers = 1 << 1, + VanillaTypeHandlerAdapter = 1 << 2, } } diff --git a/src/Dapper.AOT.Analyzers/Internal/Types.cs b/src/Dapper.AOT.Analyzers/Internal/Types.cs index 0bd83bf3..57ecbf27 100644 --- a/src/Dapper.AOT.Analyzers/Internal/Types.cs +++ b/src/Dapper.AOT.Analyzers/Internal/Types.cs @@ -9,6 +9,7 @@ public const string ColumnAttribute = nameof(ColumnAttribute), CommandPropertyAttribute = nameof(CommandPropertyAttribute), DapperAotAttribute = nameof(DapperAotAttribute), + TypeHandlerAttribute = nameof(TypeHandlerAttribute), DbValueAttribute = nameof(DbValueAttribute), DynamicParameters = nameof(DynamicParameters), ExplicitConstructorAttribute = nameof(ExplicitConstructorAttribute), diff --git a/src/Dapper.AOT/DbValueHandler.cs b/src/Dapper.AOT/DbValueHandler.cs new file mode 100644 index 00000000..97c9c35b --- /dev/null +++ b/src/Dapper.AOT/DbValueHandler.cs @@ -0,0 +1,121 @@ +using System; +using System.ComponentModel; +using System.Data.Common; + +namespace Dapper; + +/// +/// Specify the handler type that should be used to read and write values of a given type; the +/// handler must implement +/// (usually by inheriting ), or be a vanilla Dapper +/// SqlMapper.ITypeHandler, in which case generated code adapts it. +/// +/// +/// This is the replacement for runtime registration via SqlMapper.AddTypeHandler: it is +/// per-assembly rather than process-global, deterministic (no startup-ordering races), visible in +/// review, and known at compile time, so the generator can bake the dispatch. +/// +/// Deliberately not generic, and deliberately not [Conditional]: generic attributes +/// cannot be read by .NET Framework's GetCustomAttributes (it throws for the whole call, +/// which would poison unrelated reflection over the assembly), and the metadata must survive the +/// build for a package to declare handlers for the types it owns. +/// +/// +/// +/// Assembly and module scope only, for now. Narrower scopes (a handler for one member or one +/// parameter) are a plausible future addition - widening the targets and adding a constructor +/// would both be non-breaking - but an attribute that compiles and does nothing is the exact +/// problem this replacement exists to remove, so the form does not ship before it is read. +/// +[ImmutableObject(true)] +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Module, AllowMultiple = true)] +public sealed class TypeHandlerAttribute : Attribute +{ + /// + /// Register for all values of . + /// + public TypeHandlerAttribute(Type valueType, Type handlerType) + { + ValueType = valueType; + HandlerType = handlerType; + } + + /// + /// The type of value handled. + /// + public Type ValueType { get; } + + /// + /// The handler type; it must have a public parameterless constructor. + /// + public Type HandlerType { get; } +} + +/// +/// Reads and writes values of type , replacing the conversion that +/// generated code would otherwise perform. +/// +/// +/// Implement via unless you need to control every member. The +/// interface (rather than a base class) is the contract because generated code implements it to +/// adapt handlers from other libraries - notably vanilla Dapper's, which this library cannot +/// reference: a consumer may be using Dapper or Dapper.StrongName, and referencing either would +/// load both and split the registry. +/// +public interface IDbValueHandler +{ + /// Configure and assign a non-null value. + void SetValue(DbParameter parameter, T value); + + /// Configure and assign a null value. + void SetNullValue(DbParameter parameter); + + /// Interpret the value of an output parameter. + T Parse(DbParameter parameter); + + /// + /// Inspect a column once per query, returning a token that is passed to + /// for every row - so per-row type tests are paid + /// once, matching how generated row factories work. + /// + int Tokenize(DbDataReader reader, int columnOffset); + + /// Read a value from a column, using the token from . + T Parse(DbDataReader reader, int ordinal, int token); +} + +/// +/// Convenience base class for ; override what you need. +/// +public abstract class DbValueHandler : IDbValueHandler +{ + /// Configure a parameter (type, size, etc); applied for null and non-null alike. + protected virtual void Configure(DbParameter parameter) { } + + /// Assign a non-null value, after . + protected abstract void SetValueCore(DbParameter parameter, T value); + + void IDbValueHandler.SetValue(DbParameter parameter, T value) + { + Configure(parameter); + SetValueCore(parameter, value); + } + + void IDbValueHandler.SetNullValue(DbParameter parameter) + { + Configure(parameter); + parameter.Value = DBNull.Value; + } + + /// + public virtual T Parse(DbParameter parameter) => Parse(parameter.Value); + + /// + public virtual int Tokenize(DbDataReader reader, int columnOffset) => 0; + + /// + public virtual T Parse(DbDataReader reader, int ordinal, int token) => Parse(reader.GetValue(ordinal)); + + /// Interpret a raw value obtained from ADO.NET. + protected abstract T Parse(object? value); +} diff --git a/src/Dapper.AOT/TypeHandlerT.cs b/src/Dapper.AOT/TypeHandlerT.cs index 51b15bd7..b75d9a79 100644 --- a/src/Dapper.AOT/TypeHandlerT.cs +++ b/src/Dapper.AOT/TypeHandlerT.cs @@ -11,6 +11,7 @@ namespace Dapper; /// [ImmutableObject(true)] [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Module | AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method, AllowMultiple = true)] +[Obsolete("This registration was never implemented: nothing in the analyzer or generator has ever read it, so it has always been a no-op. Use the non-generic [TypeHandler(typeof(TValue), typeof(THandler))] with a handler implementing IDbValueHandler.", error: true)] public sealed class TypeHandlerAttribute : Attribute where TTypeHandler : TypeHandler, new() {} @@ -18,6 +19,7 @@ public sealed class TypeHandlerAttribute : Attribute /// /// Process a parameter value of type /// +[Obsolete("This handler shape was never implemented: it exists only as the constraint of the obsolete generic [TypeHandler<,>] attribute, which was never read. Use IDbValueHandler (or the DbValueHandler base class), registered with [TypeHandler(typeof(T), typeof(THandler))].", error: true)] public abstract class TypeHandler { /// diff --git a/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.input.cs b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.input.cs new file mode 100644 index 00000000..58a631f9 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.input.cs @@ -0,0 +1,64 @@ +#nullable enable +using Dapper; +using System.Data; +using System.Data.Common; + +// The fixture from external PRs #117 (samcragg) and #162 (7amou3) - which propose the same four +// scenarios, in the same shapes - translated to the registration and handler contract this PR +// ships. Call sites, type names and member names are kept verbatim so the two can be compared +// directly; only the two spellings differ: +// +// theirs [module: TypeHandler] +// here [module: TypeHandler(typeof(CustomClass), typeof(CustomClassTypeHandler))] +// +// theirs class CustomClassTypeHandler : TypeHandler +// here class CustomClassTypeHandler : DbValueHandler +// +// The generic attribute cannot be read by .NET Framework's GetCustomAttributes (it throws for +// the whole call, poisoning unrelated reflection), hence the typeof form. + +[module: DapperAot] +[module: TypeHandler(typeof(CustomClass), typeof(CustomClassTypeHandler))] + +public class CustomClass +{ + public string? Value { get; set; } +} + +public class CustomClassTypeHandler : DbValueHandler +{ + // theirs left the handler empty, relying on base-class defaults; here SetValueCore and + // Parse are abstract, because a handler that handles nothing is a mistake worth a compiler + // error rather than a silent pass-through + protected override void SetValueCore(DbParameter parameter, CustomClass value) + => parameter.Value = value.Value; + + protected override CustomClass Parse(object? value) + => new CustomClass { Value = value as string }; +} + +public static class Foo +{ + static void SomeCode(DbConnection connection, string bar, bool isBuffered) + { + // (1) read: a member of the handled type, on a mapped row type + _ = connection.Query("def"); + + // (2) write: a member of the handled type, on an *anonymous* parameter type + _ = connection.Query("def", new { Param = new CustomClass() }); + + // (3) output parameter of the handled type, read back through the handler + _ = connection.Query("@OutputValue = def", new CommandParameters()); + } + + public class CommandParameters + { + [DbValue(Direction = ParameterDirection.Output)] + public CustomClass? OutputValue { get; set; } + } + + public class MyType + { + public CustomClass? C { get; set; } + } +} diff --git a/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.cs b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.cs new file mode 100644 index 00000000..2bc5a70c --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.cs @@ -0,0 +1,215 @@ +#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\\TypeHandlerPriorArt.input.cs", 45, 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, Buffered, StoredProcedure, BindResultsByName + // returns data: global::Foo.MyType + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.StoredProcedure); + global::System.Diagnostics.Debug.Assert(buffered is true); + global::System.Diagnostics.Debug.Assert(param is null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.StoredProcedure, commandTimeout.GetValueOrDefault(), DefaultCommandFactory).QueryBuffered(param, RowFactory0.Instance); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerPriorArt.input.cs", 48, 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, StoredProcedure, KnownParameters + // takes parameter: + // parameter map: Param + // 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.StoredProcedure); + 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.StoredProcedure, commandTimeout.GetValueOrDefault(), CommandFactory0.Instance).QueryBuffered(param, global::Dapper.RowFactory.Inbuilt.Value()); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerPriorArt.input.cs", 51, 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: global::Foo.CommandParameters + // parameter map: OutputValue + // 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((global::Foo.CommandParameters)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 RowFactory0 : global::Dapper.RowFactory + { + internal static readonly RowFactory0 Instance = new(); + private RowFactory0() {} + public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span tokens, int columnOffset) + { + var handlerTokens = new int[tokens.Length]; + var handlerOffset = columnOffset; // the loop below advances columnOffset + for (int i = 0; i < tokens.Length; i++) + { + int token = -1; + var name = reader.GetName(columnOffset); + var type = reader.GetFieldType(columnOffset); + switch (NormalizedHash(name)) + { + case 3859557458U when NormalizedEquals(name, "c"): + token = 1; // type-handler: the handler decides + break; + + } + tokens[i] = token; + columnOffset++; + + } + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: case 1: + handlerTokens[i] = TypeHandler0.Tokenize(reader, handlerOffset + i); + break; + + } + + } + return handlerTokens; + } + public override global::Foo.MyType Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Foo.MyType result = new(); + var handlerTokens = (int[])state!; + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: + result.C = reader.IsDBNull(columnOffset) ? (global::CustomClass?)null : TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 1: + result.C = reader.IsDBNull(columnOffset) ? (global::CustomClass?)null : TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + 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 { Param = default(global::CustomClass) }); // expected shape + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Param"; + p.Direction = global::System.Data.ParameterDirection.Input; + if (typed.Param is null) TypeHandler0.SetNullValue(p); + else TypeHandler0.SetValue(p, typed.Param); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { Param = default(global::CustomClass) }); // expected shape + var ps = cmd.Parameters; + if (typed.Param is null) TypeHandler0.SetNullValue(ps[0]); + else TypeHandler0.SetValue(ps[0], typed.Param); + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory + { + internal static readonly CommandFactory1 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Foo.CommandParameters args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "OutputValue"; + p.Direction = global::System.Data.ParameterDirection.Output; + p.Value = global::System.DBNull.Value; + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Foo.CommandParameters args) + { + var ps = cmd.Parameters; + ps[0].Value = global::System.DBNull.Value; + + } + public override bool RequirePostProcess => true; + + public override void PostProcess(in global::Dapper.UnifiedCommand cmd, global::Foo.CommandParameters args, int rowCount) + { + var ps = cmd.Parameters; + args.OutputValue = TypeHandler0.Parse(ps[0]); + base.PostProcess(in cmd, args, rowCount); + + } + + } + + + private static readonly global::Dapper.IDbValueHandler TypeHandler0 = new global::CustomClassTypeHandler(); + + } +} +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/TypeHandlerPriorArt.output.netfx.cs b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.netfx.cs new file mode 100644 index 00000000..2bc5a70c --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.netfx.cs @@ -0,0 +1,215 @@ +#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\\TypeHandlerPriorArt.input.cs", 45, 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, Buffered, StoredProcedure, BindResultsByName + // returns data: global::Foo.MyType + global::System.Diagnostics.Debug.Assert(!string.IsNullOrWhiteSpace(sql)); + global::System.Diagnostics.Debug.Assert((commandType ?? global::Dapper.DapperAotExtensions.GetCommandType(sql)) == global::System.Data.CommandType.StoredProcedure); + global::System.Diagnostics.Debug.Assert(buffered is true); + global::System.Diagnostics.Debug.Assert(param is null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.StoredProcedure, commandTimeout.GetValueOrDefault(), DefaultCommandFactory).QueryBuffered(param, RowFactory0.Instance); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerPriorArt.input.cs", 48, 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, StoredProcedure, KnownParameters + // takes parameter: + // parameter map: Param + // 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.StoredProcedure); + 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.StoredProcedure, commandTimeout.GetValueOrDefault(), CommandFactory0.Instance).QueryBuffered(param, global::Dapper.RowFactory.Inbuilt.Value()); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerPriorArt.input.cs", 51, 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: global::Foo.CommandParameters + // parameter map: OutputValue + // 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((global::Foo.CommandParameters)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 RowFactory0 : global::Dapper.RowFactory + { + internal static readonly RowFactory0 Instance = new(); + private RowFactory0() {} + public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span tokens, int columnOffset) + { + var handlerTokens = new int[tokens.Length]; + var handlerOffset = columnOffset; // the loop below advances columnOffset + for (int i = 0; i < tokens.Length; i++) + { + int token = -1; + var name = reader.GetName(columnOffset); + var type = reader.GetFieldType(columnOffset); + switch (NormalizedHash(name)) + { + case 3859557458U when NormalizedEquals(name, "c"): + token = 1; // type-handler: the handler decides + break; + + } + tokens[i] = token; + columnOffset++; + + } + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: case 1: + handlerTokens[i] = TypeHandler0.Tokenize(reader, handlerOffset + i); + break; + + } + + } + return handlerTokens; + } + public override global::Foo.MyType Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Foo.MyType result = new(); + var handlerTokens = (int[])state!; + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: + result.C = reader.IsDBNull(columnOffset) ? (global::CustomClass?)null : TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 1: + result.C = reader.IsDBNull(columnOffset) ? (global::CustomClass?)null : TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + 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 { Param = default(global::CustomClass) }); // expected shape + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Param"; + p.Direction = global::System.Data.ParameterDirection.Input; + if (typed.Param is null) TypeHandler0.SetNullValue(p); + else TypeHandler0.SetValue(p, typed.Param); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, object? args) + { + var typed = Cast(args, static () => new { Param = default(global::CustomClass) }); // expected shape + var ps = cmd.Parameters; + if (typed.Param is null) TypeHandler0.SetNullValue(ps[0]); + else TypeHandler0.SetValue(ps[0], typed.Param); + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory + { + internal static readonly CommandFactory1 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Foo.CommandParameters args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "OutputValue"; + p.Direction = global::System.Data.ParameterDirection.Output; + p.Value = global::System.DBNull.Value; + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Foo.CommandParameters args) + { + var ps = cmd.Parameters; + ps[0].Value = global::System.DBNull.Value; + + } + public override bool RequirePostProcess => true; + + public override void PostProcess(in global::Dapper.UnifiedCommand cmd, global::Foo.CommandParameters args, int rowCount) + { + var ps = cmd.Parameters; + args.OutputValue = TypeHandler0.Parse(ps[0]); + base.PostProcess(in cmd, args, rowCount); + + } + + } + + + private static readonly global::Dapper.IDbValueHandler TypeHandler0 = new global::CustomClassTypeHandler(); + + } +} +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/TypeHandlerPriorArt.output.netfx.txt b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.netfx.txt new file mode 100644 index 00000000..a6259a59 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.netfx.txt @@ -0,0 +1,4 @@ +Generator produced 1 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 3 of 3 enabled call-sites (0 unsupported API, 0 skipped due to diagnostics) using 3 interceptors, 2 commands and 1 readers diff --git a/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.txt b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.txt new file mode 100644 index 00000000..a6259a59 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerPriorArt.output.txt @@ -0,0 +1,4 @@ +Generator produced 1 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 3 of 3 enabled call-sites (0 unsupported API, 0 skipped due to diagnostics) using 3 interceptors, 2 commands and 1 readers diff --git a/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.input.cs b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.input.cs new file mode 100644 index 00000000..b8aadfa5 --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.input.cs @@ -0,0 +1,84 @@ +#nullable enable +using Dapper; +using System; +using System.Data; +using System.Data.Common; + +[module: DapperAot] + +// declarative registration: per-assembly, deterministic, and visible to the generator, unlike +// SqlMapper.AddTypeHandler. Non-generic on purpose - a generic attribute cannot be read by +// .NET Framework's GetCustomAttributes +[module: TypeHandler(typeof(LocalDate), typeof(LocalDateHandler))] +[module: TypeHandler(typeof(Money), typeof(MoneyHandler))] + +public static class Foo +{ + static void SomeCode(DbConnection connection) + { + // native handler (IDbValueHandler) on the write and read paths + _ = connection.Query("select * from Appointments where Day = @Day", + new Appointment { Day = new LocalDate() }); + + // a nullable member: null goes to SetNullValue, so a handler over a struct never sees a + // null it cannot express + _ = connection.Execute("update Appointments set MovedTo = @MovedTo where Day = @Day", + new Appointment { Day = new LocalDate(), MovedTo = null }); + + // vanilla Dapper handler, reached through the generated adapter + _ = connection.Query("select * from Invoices where Total = @Total", + new Invoice { Total = new Money() }); + + // output parameters read back through the handler + _ = connection.Execute("exec NextAppointment @Day out", new AppointmentOut()); + } +} + +public struct LocalDate { public int Year, Month, Day; } +public struct Money { public decimal Amount; } + +public class Appointment +{ + public LocalDate Day { get; set; } + public LocalDate? MovedTo { get; set; } +} + +public class AppointmentOut +{ + [DbValue(Direction = ParameterDirection.Output)] + public LocalDate Day { get; set; } +} + +public class Invoice +{ + public Money Total { get; set; } +} + +// the new shape: the generator emits a single static and calls it directly. +// Tokenize runs once per column per query and its result is handed back to Parse for every +// row, so a per-column decision (here: which shape the provider gave us) is paid once +public sealed class LocalDateHandler : DbValueHandler +{ + protected override void Configure(DbParameter parameter) => parameter.DbType = DbType.Date; + protected override void SetValueCore(DbParameter parameter, LocalDate value) + => parameter.Value = new DateTime(value.Year, value.Month, value.Day); + + public override int Tokenize(DbDataReader reader, int columnOffset) + => reader.GetFieldType(columnOffset) == typeof(string) ? 1 : 0; + + public override LocalDate Parse(DbDataReader reader, int ordinal, int token) + => Parse(token == 1 ? DateTime.Parse(reader.GetString(ordinal)) : reader.GetValue(ordinal)); + + protected override LocalDate Parse(object? value) + { + var when = (DateTime)value!; + return new LocalDate { Year = when.Year, Month = when.Month, Day = when.Day }; + } +} + +// the old shape, written against vanilla Dapper: still usable, via the generated shim +public sealed class MoneyHandler : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, Money value) => parameter.Value = value.Amount; + public override Money Parse(object value) => new Money { Amount = (decimal)value }; +} diff --git a/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.cs b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.cs new file mode 100644 index 00000000..82f778ac --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.cs @@ -0,0 +1,393 @@ +#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\\TypeHandlerRegistration.input.cs", 20, 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, BindResultsByName, KnownParameters + // takes parameter: global::Appointment + // parameter map: Day + // returns data: global::Appointment + 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((global::Appointment)param!, RowFactory0.Instance); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerRegistration.input.cs", 25, 24)] + internal static int Execute1(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Execute, HasParameters, Text, KnownParameters + // takes parameter: global::Appointment + // parameter map: Day MovedTo + 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(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory1.Instance).Execute((global::Appointment)param!); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerRegistration.input.cs", 29, 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, BindResultsByName, KnownParameters + // takes parameter: global::Invoice + // parameter map: Total + // returns data: global::Invoice + 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((global::Invoice)param!, RowFactory1.Instance); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerRegistration.input.cs", 33, 24)] + internal static int Execute3(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Execute, HasParameters, Text, KnownParameters + // takes parameter: global::AppointmentOut + // parameter map: Day + 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(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory3.Instance).Execute((global::AppointmentOut)param!); + + } + + 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 RowFactory0 : global::Dapper.RowFactory + { + internal static readonly RowFactory0 Instance = new(); + private RowFactory0() {} + public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span tokens, int columnOffset) + { + var handlerTokens = new int[tokens.Length]; + var handlerOffset = columnOffset; // the loop below advances columnOffset + for (int i = 0; i < tokens.Length; i++) + { + int token = -1; + var name = reader.GetName(columnOffset); + var type = reader.GetFieldType(columnOffset); + switch (NormalizedHash(name)) + { + case 3830391293U when NormalizedEquals(name, "day"): + token = 2; // type-handler: the handler decides + break; + case 2725939961U when NormalizedEquals(name, "movedto"): + token = 3; // type-handler: the handler decides + break; + + } + tokens[i] = token; + columnOffset++; + + } + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: case 2: case 1: case 3: + handlerTokens[i] = TypeHandler0.Tokenize(reader, handlerOffset + i); + break; + + } + + } + return handlerTokens; + } + public override global::Appointment Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Appointment result = new(); + var handlerTokens = (int[])state!; + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: + result.Day = TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 2: + result.Day = TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 1: + result.MovedTo = reader.IsDBNull(columnOffset) ? (global::LocalDate?)null : TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 3: + result.MovedTo = reader.IsDBNull(columnOffset) ? (global::LocalDate?)null : TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + private sealed class RowFactory1 : global::Dapper.RowFactory + { + internal static readonly RowFactory1 Instance = new(); + private RowFactory1() {} + public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span tokens, int columnOffset) + { + var handlerTokens = new int[tokens.Length]; + var handlerOffset = columnOffset; // the loop below advances columnOffset + for (int i = 0; i < tokens.Length; i++) + { + int token = -1; + var name = reader.GetName(columnOffset); + var type = reader.GetFieldType(columnOffset); + switch (NormalizedHash(name)) + { + case 80777981U when NormalizedEquals(name, "total"): + token = 1; // type-handler: the handler decides + break; + + } + tokens[i] = token; + columnOffset++; + + } + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: case 1: + handlerTokens[i] = TypeHandler1.Tokenize(reader, handlerOffset + i); + break; + + } + + } + return handlerTokens; + } + public override global::Invoice Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Invoice result = new(); + var handlerTokens = (int[])state!; + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: + result.Total = TypeHandler1.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 1: + result.Total = TypeHandler1.Parse(reader, columnOffset, handlerTokens[i]); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + private sealed class CommandFactory0 : CommonCommandFactory + { + internal static readonly CommandFactory0 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Appointment args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Day"; + p.Direction = global::System.Data.ParameterDirection.Input; + TypeHandler0.SetValue(p, args.Day); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Appointment args) + { + var ps = cmd.Parameters; + TypeHandler0.SetValue(ps[0], args.Day); + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory + { + internal static readonly CommandFactory1 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Appointment args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Day"; + p.Direction = global::System.Data.ParameterDirection.Input; + TypeHandler0.SetValue(p, args.Day); + ps.Add(p); + + p = cmd.CreateParameter(); + p.ParameterName = "MovedTo"; + p.Direction = global::System.Data.ParameterDirection.Input; + if (args.MovedTo is null) TypeHandler0.SetNullValue(p); + else TypeHandler0.SetValue(p, args.MovedTo.GetValueOrDefault()); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Appointment args) + { + var ps = cmd.Parameters; + TypeHandler0.SetValue(ps[0], args.Day); + if (args.MovedTo is null) TypeHandler0.SetNullValue(ps[1]); + else TypeHandler0.SetValue(ps[1], args.MovedTo.GetValueOrDefault()); + + } + + } + + private sealed class CommandFactory2 : CommonCommandFactory + { + internal static readonly CommandFactory2 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Invoice args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Total"; + p.Direction = global::System.Data.ParameterDirection.Input; + TypeHandler1.SetValue(p, args.Total); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Invoice args) + { + var ps = cmd.Parameters; + TypeHandler1.SetValue(ps[0], args.Total); + + } + + } + + private sealed class CommandFactory3 : CommonCommandFactory + { + internal static readonly CommandFactory3 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::AppointmentOut args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Day"; + p.Direction = global::System.Data.ParameterDirection.Output; + p.Value = global::System.DBNull.Value; + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::AppointmentOut args) + { + var ps = cmd.Parameters; + ps[0].Value = global::System.DBNull.Value; + + } + public override bool RequirePostProcess => true; + + public override void PostProcess(in global::Dapper.UnifiedCommand cmd, global::AppointmentOut args, int rowCount) + { + var ps = cmd.Parameters; + args.Day = TypeHandler0.Parse(ps[0]); + base.PostProcess(in cmd, args, rowCount); + + } + + } + + + private static readonly global::Dapper.IDbValueHandler TypeHandler0 = new global::LocalDateHandler(); + private static readonly global::Dapper.IDbValueHandler TypeHandler1 = new global::Dapper.Aot.Generated.VanillaTypeHandler(new global::MoneyHandler()); + + } +} +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; + } + } +} +namespace Dapper.Aot.Generated +{ + /// + /// Adapts a vanilla Dapper type-handler (SqlMapper.ITypeHandler) to Dapper.AOT's + /// IDbValueHandler{T}. + /// + /// + /// The runtime library cannot reference Dapper: a consumer may be using Dapper or + /// Dapper.StrongName, and referencing either would load both and split the handler registry. + /// Generated code has no such problem - it compiles against whichever one the consumer + /// actually references - so the shim is emitted here rather than shipped. + /// +#if !DAPPERAOT_INTERNAL + file +#endif + sealed class VanillaTypeHandler : global::Dapper.IDbValueHandler + { + private readonly global::Dapper.SqlMapper.ITypeHandler _inner; + public VanillaTypeHandler(global::Dapper.SqlMapper.ITypeHandler inner) => _inner = inner; + + public void SetValue(global::System.Data.Common.DbParameter parameter, T value) + // vanilla's handlers special-case DBNull and can fault on a raw null (the struct cast + // in TypeHandler's explicit interface implementation); vanilla coalesces first, so + // we do too + => _inner.SetValue(parameter, value is null ? (object)global::System.DBNull.Value : value); + + public void SetNullValue(global::System.Data.Common.DbParameter parameter) + => _inner.SetValue(parameter, global::System.DBNull.Value); + + public T Parse(global::System.Data.Common.DbParameter parameter) + => Convert(parameter.Value); + + public int Tokenize(global::System.Data.Common.DbDataReader reader, int columnOffset) => 0; + + public T Parse(global::System.Data.Common.DbDataReader reader, int ordinal, int token) + => Convert(reader.GetValue(ordinal)); + + private T Convert(object? value) + => value is null or global::System.DBNull ? default! : (T)_inner.Parse(typeof(T), value)!; + } +} diff --git a/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.netfx.cs b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.netfx.cs new file mode 100644 index 00000000..82f778ac --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.netfx.cs @@ -0,0 +1,393 @@ +#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\\TypeHandlerRegistration.input.cs", 20, 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, BindResultsByName, KnownParameters + // takes parameter: global::Appointment + // parameter map: Day + // returns data: global::Appointment + 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((global::Appointment)param!, RowFactory0.Instance); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerRegistration.input.cs", 25, 24)] + internal static int Execute1(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Execute, HasParameters, Text, KnownParameters + // takes parameter: global::Appointment + // parameter map: Day MovedTo + 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(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory1.Instance).Execute((global::Appointment)param!); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerRegistration.input.cs", 29, 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, BindResultsByName, KnownParameters + // takes parameter: global::Invoice + // parameter map: Total + // returns data: global::Invoice + 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((global::Invoice)param!, RowFactory1.Instance); + + } + + [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("Interceptors\\TypeHandlerRegistration.input.cs", 33, 24)] + internal static int Execute3(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, int? commandTimeout, global::System.Data.CommandType? commandType) + { + // Execute, HasParameters, Text, KnownParameters + // takes parameter: global::AppointmentOut + // parameter map: Day + 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(param is not null); + + return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory3.Instance).Execute((global::AppointmentOut)param!); + + } + + 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 RowFactory0 : global::Dapper.RowFactory + { + internal static readonly RowFactory0 Instance = new(); + private RowFactory0() {} + public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span tokens, int columnOffset) + { + var handlerTokens = new int[tokens.Length]; + var handlerOffset = columnOffset; // the loop below advances columnOffset + for (int i = 0; i < tokens.Length; i++) + { + int token = -1; + var name = reader.GetName(columnOffset); + var type = reader.GetFieldType(columnOffset); + switch (NormalizedHash(name)) + { + case 3830391293U when NormalizedEquals(name, "day"): + token = 2; // type-handler: the handler decides + break; + case 2725939961U when NormalizedEquals(name, "movedto"): + token = 3; // type-handler: the handler decides + break; + + } + tokens[i] = token; + columnOffset++; + + } + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: case 2: case 1: case 3: + handlerTokens[i] = TypeHandler0.Tokenize(reader, handlerOffset + i); + break; + + } + + } + return handlerTokens; + } + public override global::Appointment Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Appointment result = new(); + var handlerTokens = (int[])state!; + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: + result.Day = TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 2: + result.Day = TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 1: + result.MovedTo = reader.IsDBNull(columnOffset) ? (global::LocalDate?)null : TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 3: + result.MovedTo = reader.IsDBNull(columnOffset) ? (global::LocalDate?)null : TypeHandler0.Parse(reader, columnOffset, handlerTokens[i]); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + private sealed class RowFactory1 : global::Dapper.RowFactory + { + internal static readonly RowFactory1 Instance = new(); + private RowFactory1() {} + public override object? Tokenize(global::System.Data.Common.DbDataReader reader, global::System.Span tokens, int columnOffset) + { + var handlerTokens = new int[tokens.Length]; + var handlerOffset = columnOffset; // the loop below advances columnOffset + for (int i = 0; i < tokens.Length; i++) + { + int token = -1; + var name = reader.GetName(columnOffset); + var type = reader.GetFieldType(columnOffset); + switch (NormalizedHash(name)) + { + case 80777981U when NormalizedEquals(name, "total"): + token = 1; // type-handler: the handler decides + break; + + } + tokens[i] = token; + columnOffset++; + + } + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: case 1: + handlerTokens[i] = TypeHandler1.Tokenize(reader, handlerOffset + i); + break; + + } + + } + return handlerTokens; + } + public override global::Invoice Read(global::System.Data.Common.DbDataReader reader, global::System.ReadOnlySpan tokens, int columnOffset, object? state) + { + global::Invoice result = new(); + var handlerTokens = (int[])state!; + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: + result.Total = TypeHandler1.Parse(reader, columnOffset, handlerTokens[i]); + break; + case 1: + result.Total = TypeHandler1.Parse(reader, columnOffset, handlerTokens[i]); + break; + + } + columnOffset++; + + } + return result; + + } + + } + + private sealed class CommandFactory0 : CommonCommandFactory + { + internal static readonly CommandFactory0 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Appointment args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Day"; + p.Direction = global::System.Data.ParameterDirection.Input; + TypeHandler0.SetValue(p, args.Day); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Appointment args) + { + var ps = cmd.Parameters; + TypeHandler0.SetValue(ps[0], args.Day); + + } + + } + + private sealed class CommandFactory1 : CommonCommandFactory + { + internal static readonly CommandFactory1 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Appointment args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Day"; + p.Direction = global::System.Data.ParameterDirection.Input; + TypeHandler0.SetValue(p, args.Day); + ps.Add(p); + + p = cmd.CreateParameter(); + p.ParameterName = "MovedTo"; + p.Direction = global::System.Data.ParameterDirection.Input; + if (args.MovedTo is null) TypeHandler0.SetNullValue(p); + else TypeHandler0.SetValue(p, args.MovedTo.GetValueOrDefault()); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Appointment args) + { + var ps = cmd.Parameters; + TypeHandler0.SetValue(ps[0], args.Day); + if (args.MovedTo is null) TypeHandler0.SetNullValue(ps[1]); + else TypeHandler0.SetValue(ps[1], args.MovedTo.GetValueOrDefault()); + + } + + } + + private sealed class CommandFactory2 : CommonCommandFactory + { + internal static readonly CommandFactory2 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::Invoice args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Total"; + p.Direction = global::System.Data.ParameterDirection.Input; + TypeHandler1.SetValue(p, args.Total); + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::Invoice args) + { + var ps = cmd.Parameters; + TypeHandler1.SetValue(ps[0], args.Total); + + } + + } + + private sealed class CommandFactory3 : CommonCommandFactory + { + internal static readonly CommandFactory3 Instance = new(); + public override void AddParameters(in global::Dapper.UnifiedCommand cmd, global::AppointmentOut args) + { + var ps = cmd.Parameters; + global::System.Data.Common.DbParameter p; + p = cmd.CreateParameter(); + p.ParameterName = "Day"; + p.Direction = global::System.Data.ParameterDirection.Output; + p.Value = global::System.DBNull.Value; + ps.Add(p); + + } + public override void UpdateParameters(in global::Dapper.UnifiedCommand cmd, global::AppointmentOut args) + { + var ps = cmd.Parameters; + ps[0].Value = global::System.DBNull.Value; + + } + public override bool RequirePostProcess => true; + + public override void PostProcess(in global::Dapper.UnifiedCommand cmd, global::AppointmentOut args, int rowCount) + { + var ps = cmd.Parameters; + args.Day = TypeHandler0.Parse(ps[0]); + base.PostProcess(in cmd, args, rowCount); + + } + + } + + + private static readonly global::Dapper.IDbValueHandler TypeHandler0 = new global::LocalDateHandler(); + private static readonly global::Dapper.IDbValueHandler TypeHandler1 = new global::Dapper.Aot.Generated.VanillaTypeHandler(new global::MoneyHandler()); + + } +} +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; + } + } +} +namespace Dapper.Aot.Generated +{ + /// + /// Adapts a vanilla Dapper type-handler (SqlMapper.ITypeHandler) to Dapper.AOT's + /// IDbValueHandler{T}. + /// + /// + /// The runtime library cannot reference Dapper: a consumer may be using Dapper or + /// Dapper.StrongName, and referencing either would load both and split the handler registry. + /// Generated code has no such problem - it compiles against whichever one the consumer + /// actually references - so the shim is emitted here rather than shipped. + /// +#if !DAPPERAOT_INTERNAL + file +#endif + sealed class VanillaTypeHandler : global::Dapper.IDbValueHandler + { + private readonly global::Dapper.SqlMapper.ITypeHandler _inner; + public VanillaTypeHandler(global::Dapper.SqlMapper.ITypeHandler inner) => _inner = inner; + + public void SetValue(global::System.Data.Common.DbParameter parameter, T value) + // vanilla's handlers special-case DBNull and can fault on a raw null (the struct cast + // in TypeHandler's explicit interface implementation); vanilla coalesces first, so + // we do too + => _inner.SetValue(parameter, value is null ? (object)global::System.DBNull.Value : value); + + public void SetNullValue(global::System.Data.Common.DbParameter parameter) + => _inner.SetValue(parameter, global::System.DBNull.Value); + + public T Parse(global::System.Data.Common.DbParameter parameter) + => Convert(parameter.Value); + + public int Tokenize(global::System.Data.Common.DbDataReader reader, int columnOffset) => 0; + + public T Parse(global::System.Data.Common.DbDataReader reader, int ordinal, int token) + => Convert(reader.GetValue(ordinal)); + + private T Convert(object? value) + => value is null or global::System.DBNull ? default! : (T)_inner.Parse(typeof(T), value)!; + } +} diff --git a/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.netfx.txt b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.netfx.txt new file mode 100644 index 00000000..2f4cfa0b --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.netfx.txt @@ -0,0 +1,4 @@ +Generator produced 1 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 4 of 4 enabled call-sites (0 unsupported API, 0 skipped due to diagnostics) using 4 interceptors, 4 commands and 2 readers diff --git a/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.txt b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.txt new file mode 100644 index 00000000..2f4cfa0b --- /dev/null +++ b/test/Dapper.AOT.Test/Interceptors/TypeHandlerRegistration.output.txt @@ -0,0 +1,4 @@ +Generator produced 1 diagnostics: + +Hidden DAP000 L1 C1 +Dapper.AOT handled 4 of 4 enabled call-sites (0 unsupported API, 0 skipped due to diagnostics) using 4 interceptors, 4 commands and 2 readers diff --git a/test/Dapper.AOT.Test/TypeHandlerProtocolTests.cs b/test/Dapper.AOT.Test/TypeHandlerProtocolTests.cs new file mode 100644 index 00000000..4bb9caab --- /dev/null +++ b/test/Dapper.AOT.Test/TypeHandlerProtocolTests.cs @@ -0,0 +1,164 @@ +using Dapper; +using System; +using System.Data; +using System.Data.Common; +using Xunit; + +namespace Dapper.AOT.Test; + +/// +/// Pins the contract that generated row-factories rely on when a member reads through a +/// : Tokenize is a per-query, per-column decision whose +/// result reaches Parse for every row. The factory below is hand-written in the shape the +/// generator emits (see the TypeHandlerRegistration golden), so this test fails if either side +/// of that protocol drifts. +/// +public class TypeHandlerProtocolTests +{ + [Fact] + public void TokenizeRunsOncePerColumn_AndItsTokenReachesEveryRow() + { + var handler = new CountingHandler(); + var factory = new HandRolledFactory(handler); + + using var reader = CreateReader(("Day", typeof(string)), rows: 3); + var tokens = new int[reader.FieldCount]; + var state = factory.Tokenize(reader, tokens, 0); + + // one Tokenize per column, before any row is read + Assert.Equal(1, handler.TokenizeCount); + + var seen = 0; + while (reader.Read()) + { + var row = factory.Read(reader, tokens, 0, state); + Assert.Equal(2000 + seen, row.Day.Year); // the token chose the string path + seen++; + } + + Assert.Equal(3, seen); + Assert.Equal(3, handler.ParseCount); // once per row... + Assert.Equal(1, handler.TokenizeCount); // ...but still only one Tokenize + Assert.All(handler.TokensSeen, token => Assert.Equal(StringShaped, token)); + } + + [Fact] + public void TokenizeSeesTheRightColumn_WhenTheFactoryStartsPartWayAlong() + { + // multi-column readers hand a factory a slice: the handler must be asked about *its* + // column, which is what the generated `handlerOffset + i` exists to get right + var handler = new CountingHandler(); + var factory = new HandRolledFactory(handler); + + using var reader = CreateReader(("Ignored", typeof(int)), ("Day", typeof(string)), rows: 1); + var tokens = new int[1]; + var state = factory.Tokenize(reader, tokens, columnOffset: 1); + + Assert.Equal(1, handler.TokenizeCount); + Assert.Equal(1, handler.TokenizedColumn); // not column 0 + Assert.True(reader.Read()); + Assert.Equal(2000, factory.Read(reader, tokens, 1, state).Day.Year); + } + + private const int StringShaped = 1; + + private static DataTableReader CreateReader(params (string Name, Type Type)[] columns) + => CreateReader(1, columns); + + private static DataTableReader CreateReader((string Name, Type Type) column, int rows) + => CreateReader(rows, column); + + private static DataTableReader CreateReader((string Name, Type Type) a, (string Name, Type Type) b, int rows) + => CreateReader(rows, a, b); + + private static DataTableReader CreateReader(int rows, params (string Name, Type Type)[] columns) + { + var table = new DataTable(); + foreach (var (name, type) in columns) table.Columns.Add(name, type); + for (int r = 0; r < rows; r++) + { + var values = new object[columns.Length]; + for (int c = 0; c < columns.Length; c++) + { + values[c] = columns[c].Type == typeof(string) ? $"{2000 + r}-01-01" : r; + } + table.Rows.Add(values); + } + return table.CreateDataReader(); + } + + public struct LocalDate { public int Year { get; set; } } + public class Appointment { public LocalDate Day { get; set; } } + + private sealed class CountingHandler : DbValueHandler + { + public int TokenizeCount { get; private set; } + public int ParseCount { get; private set; } + public int TokenizedColumn { get; private set; } = -1; + public System.Collections.Generic.List TokensSeen { get; } = []; + + protected override void SetValueCore(DbParameter parameter, LocalDate value) + => parameter.Value = value.Year; + + public override int Tokenize(DbDataReader reader, int columnOffset) + { + TokenizeCount++; + TokenizedColumn = columnOffset; + return reader.GetFieldType(columnOffset) == typeof(string) ? StringShaped : 0; + } + + public override LocalDate Parse(DbDataReader reader, int ordinal, int token) + { + ParseCount++; + TokensSeen.Add(token); + // the whole point: the per-column decision is not re-made here + return token == StringShaped + ? new LocalDate { Year = int.Parse(reader.GetString(ordinal).Substring(0, 4)) } + : new LocalDate { Year = reader.GetInt32(ordinal) }; + } + + protected override LocalDate Parse(object? value) => throw new NotSupportedException(); + } + + /// Written the way WriteRowFactory emits for a handler-bound member. + private sealed class HandRolledFactory(CountingHandler handler) : RowFactory + { + public override object? Tokenize(DbDataReader reader, Span tokens, int columnOffset) + { + var handlerTokens = new int[tokens.Length]; + var handlerOffset = columnOffset; + for (int i = 0; i < tokens.Length; i++) + { + tokens[i] = 0; // "Day", via the handler + columnOffset++; + } + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: + handlerTokens[i] = handler.Tokenize(reader, handlerOffset + i); + break; + } + } + return handlerTokens; + } + + public override Appointment Read(DbDataReader reader, ReadOnlySpan tokens, int columnOffset, object? state) + { + Appointment result = new(); + var handlerTokens = (int[])state!; + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i]) + { + case 0: + result.Day = handler.Parse(reader, columnOffset, handlerTokens[i]); + break; + } + columnOffset++; + } + return result; + } + } +} diff --git a/test/Dapper.AOT.Test/Verifiers/DAP053.cs b/test/Dapper.AOT.Test/Verifiers/DAP053.cs new file mode 100644 index 00000000..73eaf4f3 --- /dev/null +++ b/test/Dapper.AOT.Test/Verifiers/DAP053.cs @@ -0,0 +1,81 @@ +using Dapper.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; +using static Dapper.CodeAnalysis.DapperAnalyzer; + +namespace Dapper.AOT.Test.Verifiers; + +public class DAP053 : Verifier +{ + [Fact] // a runtime registration the generator cannot see, in both spellings + public Task RuntimeRegistrationIsInvisible() => CSVerifyAsync(""" + using Dapper; + using System; + using System.Data; + + [module: DapperAot] + + public struct LocalDate { public int Year; } + + public sealed class LocalDateHandler : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, LocalDate value) { } + public override LocalDate Parse(object value) => default; + } + + public static class Startup + { + public static void Register() + { + {|#0:SqlMapper.AddTypeHandler(new LocalDateHandler())|}; + {|#1:SqlMapper.AddTypeHandler(typeof(LocalDate), new LocalDateHandler())|}; + } + } + """, DefaultConfig, [ + Diagnostic(Diagnostics.RuntimeTypeHandlerRegistration).WithLocation(0).WithArguments("LocalDate", "LocalDateHandler"), + Diagnostic(Diagnostics.RuntimeTypeHandlerRegistration).WithLocation(1).WithArguments("LocalDate", "LocalDateHandler"), + ]); + + [Fact] // declared via the attribute: the runtime call is redundant, not wrong, so: quiet + public Task DeclaredHandlerIsNotReported() => CSVerifyAsync(""" + using Dapper; + using System; + using System.Data; + + [module: DapperAot] + [module: TypeHandler(typeof(LocalDate), typeof(LocalDateHandler))] + + public struct LocalDate { public int Year; } + + public sealed class LocalDateHandler : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, LocalDate value) { } + public override LocalDate Parse(object value) => default; + } + + public static class Startup + { + public static void Register() => SqlMapper.AddTypeHandler(new LocalDateHandler()); + } + """, DefaultConfig, []); + + [Fact] // no Dapper.AOT in play: vanilla-only code is behaving correctly + public Task NotReportedWithoutDapperAot() => CSVerifyAsync(""" + using Dapper; + using System; + using System.Data; + + public struct LocalDate { public int Year; } + + public sealed class LocalDateHandler : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, LocalDate value) { } + public override LocalDate Parse(object value) => default; + } + + public static class Startup + { + public static void Register() => SqlMapper.AddTypeHandler(new LocalDateHandler()); + } + """, DefaultConfig, []); +} diff --git a/test/Dapper.AOT.Test/Verifiers/DAP054.cs b/test/Dapper.AOT.Test/Verifiers/DAP054.cs new file mode 100644 index 00000000..38d72e72 --- /dev/null +++ b/test/Dapper.AOT.Test/Verifiers/DAP054.cs @@ -0,0 +1,100 @@ +using Dapper.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; +using static Dapper.CodeAnalysis.DapperAnalyzer; + +namespace Dapper.AOT.Test.Verifiers; + +public class DAP054 : Verifier +{ + // module attributes must precede everything else, so each case declares its own header + private const string Header = """ + using Dapper; + using System; + using System.Data; + using System.Data.Common; + + [module: DapperAot] + """; + + private const string Types = """ + + public struct LocalDate { public int Year; } + public struct Money { public decimal Amount; } + """; + + [Fact] // the handler implements neither contract - the commonest way to get this wrong + public Task NeitherContract() => CSVerifyAsync(Header + """ + [module: {|#0:TypeHandler(typeof(LocalDate), typeof(NotAHandler))|}] + """ + Types + """ + + public sealed class NotAHandler { } + """, DefaultConfig, [ + Diagnostic(Diagnostics.UnusableTypeHandler).WithLocation(0) + .WithArguments("NotAHandler", "LocalDate", "it implements neither IDbValueHandler nor SqlMapper.ITypeHandler"), + ]); + + [Fact] // a real handler, registered against the wrong value type: name both sides + public Task WrongValueType() => CSVerifyAsync(Header + """ + [module: {|#0:TypeHandler(typeof(Money), typeof(LocalDateHandler))|}] + """ + Types + """ + + public sealed class LocalDateHandler : DbValueHandler + { + protected override void SetValueCore(DbParameter parameter, LocalDate value) { } + protected override LocalDate Parse(object? value) => default; + } + """, DefaultConfig, [ + Diagnostic(Diagnostics.UnusableTypeHandler).WithLocation(0) + .WithArguments("LocalDateHandler", "Money", "it handles 'LocalDate', not 'Money'"), + ]); + + [Fact] // generated code has to construct it + public Task NoPublicParameterlessConstructor() => CSVerifyAsync(Header + """ + [module: {|#0:TypeHandler(typeof(LocalDate), typeof(NeedsArgs))|}] + """ + Types + """ + + public sealed class NeedsArgs : DbValueHandler + { + public NeedsArgs(int scale) { } + protected override void SetValueCore(DbParameter parameter, LocalDate value) { } + protected override LocalDate Parse(object? value) => default; + } + """, DefaultConfig, [ + Diagnostic(Diagnostics.UnusableTypeHandler).WithLocation(0) + .WithArguments("NeedsArgs", "LocalDate", "it has no public parameterless constructor"), + ]); + + [Fact] // abstract cannot be instantiated + public Task Abstract() => CSVerifyAsync(Header + """ + [module: {|#0:TypeHandler(typeof(LocalDate), typeof(AbstractHandler))|}] + """ + Types + """ + + public abstract class AbstractHandler : DbValueHandler + { + protected override LocalDate Parse(object? value) => default; + } + """, DefaultConfig, [ + Diagnostic(Diagnostics.UnusableTypeHandler).WithLocation(0) + .WithArguments("AbstractHandler", "LocalDate", "it is abstract"), + ]); + + [Fact] // both good shapes stay quiet: native, and vanilla-via-the-shim + public Task UsableHandlersAreQuiet() => CSVerifyAsync(Header + """ + [module: TypeHandler(typeof(LocalDate), typeof(LocalDateHandler))] + [module: TypeHandler(typeof(Money), typeof(MoneyHandler))] + """ + Types + """ + + public sealed class LocalDateHandler : DbValueHandler + { + protected override void SetValueCore(DbParameter parameter, LocalDate value) { } + protected override LocalDate Parse(object? value) => default; + } + + public sealed class MoneyHandler : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, Money value) { } + public override Money Parse(object value) => default; + } + """, DefaultConfig, []); +} diff --git a/test/Dapper.AOT.Test/Verifiers/DAP055.cs b/test/Dapper.AOT.Test/Verifiers/DAP055.cs new file mode 100644 index 00000000..b242c19e --- /dev/null +++ b/test/Dapper.AOT.Test/Verifiers/DAP055.cs @@ -0,0 +1,95 @@ +using Dapper.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; +using static Dapper.CodeAnalysis.DapperAnalyzer; + +namespace Dapper.AOT.Test.Verifiers; + +public class DAP055 : Verifier +{ + private const string Handlers = """ + + public struct LocalDate { public int Year; } + + public sealed class FirstHandler : DbValueHandler + { + protected override void SetValueCore(DbParameter parameter, LocalDate value) { } + protected override LocalDate Parse(object? value) => default; + } + + public sealed class SecondHandler : DbValueHandler + { + protected override void SetValueCore(DbParameter parameter, LocalDate value) { } + protected override LocalDate Parse(object? value) => default; + } + """; + + [Fact] // two different handlers for one type: no correct resolution, so name the loser + public Task ConflictingRegistrations() => CSVerifyAsync(""" + using Dapper; + using System.Data.Common; + + [module: DapperAot] + [module: TypeHandler(typeof(LocalDate), typeof(FirstHandler))] + [module: {|#0:TypeHandler(typeof(LocalDate), typeof(SecondHandler))|}] + """ + Handlers, DefaultConfig, [ + Diagnostic(Diagnostics.DuplicateTypeHandler).WithLocation(0) + .WithArguments("LocalDate", "FirstHandler", "SecondHandler"), + ]); + + [Fact] // ...and the report survives the two scopes being mixed + public Task ConflictAcrossModuleAndAssemblyScope() => CSVerifyAsync(""" + using Dapper; + using System.Data.Common; + + [module: DapperAot] + [module: TypeHandler(typeof(LocalDate), typeof(FirstHandler))] + [assembly: {|#0:TypeHandler(typeof(LocalDate), typeof(SecondHandler))|}] + """ + Handlers, DefaultConfig, [ + Diagnostic(Diagnostics.DuplicateTypeHandler).WithLocation(0) + .WithArguments("LocalDate", "FirstHandler", "SecondHandler"), + ]); + + [Fact] // an exact repeat is harmless - same handler, same outcome - so stay quiet + public Task IdenticalRepeatIsNotReported() => CSVerifyAsync(""" + using Dapper; + using System.Data.Common; + + [module: DapperAot] + [module: TypeHandler(typeof(LocalDate), typeof(FirstHandler))] + [module: TypeHandler(typeof(LocalDate), typeof(FirstHandler))] + """ + Handlers, DefaultConfig, []); + + [Fact] // a dropped duplicate is not also graded for usability: one message, not two + public Task DuplicateIsNotAlsoReportedAsUnusable() => CSVerifyAsync(""" + using Dapper; + using System.Data.Common; + + [module: DapperAot] + [module: TypeHandler(typeof(LocalDate), typeof(FirstHandler))] + [module: {|#0:TypeHandler(typeof(LocalDate), typeof(NotAHandler))|}] + + public sealed class NotAHandler { } + """ + Handlers, DefaultConfig, [ + Diagnostic(Diagnostics.DuplicateTypeHandler).WithLocation(0) + .WithArguments("LocalDate", "FirstHandler", "NotAHandler"), + ]); + + [Fact] // distinct types are not duplicates + public Task DistinctTypesAreQuiet() => CSVerifyAsync(""" + using Dapper; + using System.Data.Common; + + [module: DapperAot] + [module: TypeHandler(typeof(LocalDate), typeof(FirstHandler))] + [module: TypeHandler(typeof(Money), typeof(MoneyHandler))] + + public struct Money { public decimal Amount; } + + public sealed class MoneyHandler : DbValueHandler + { + protected override void SetValueCore(DbParameter parameter, Money value) { } + protected override Money Parse(object? value) => default; + } + """ + Handlers, DefaultConfig, []); +}