diff --git a/Directory.Build.props b/Directory.Build.props index 12e67d9..be4679d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.2.0-preview.1 + 0.2.0-preview.2 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound diff --git a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md index 2fc436c..7c95ea9 100644 --- a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md +++ b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md @@ -12,4 +12,6 @@ SQLB004 | SqlBound.Usage | Error | [SqlQuery] method must return Task<IReadOn SQLB005 | SqlBound.Usage | Error | Row type must have one public constructor with supported column types SQLB006 | SqlBound.Usage | Error | Query parameter type is not supported SQLB007 | SqlBound.Usage | Error | [SqlQuery] command text must not be empty -SQLB008 | SqlBound.Usage | Error | [SqlQuery] method must not be generic or nested in a generic type +SQLB008 | SqlBound.Usage | Error | [SqlQuery]/[SqlExecute] method must not be generic or nested in a generic type +SQLB009 | SqlBound.Usage | Error | [SqlExecute] method must return Task or Task<int> +SQLB010 | SqlBound.Usage | Error | A method cannot carry both [SqlQuery] and [SqlExecute] diff --git a/src/SqlBound.Generators/QueryMethodEmitter.cs b/src/SqlBound.Generators/QueryMethodEmitter.cs index 952ce7c..50dda79 100644 --- a/src/SqlBound.Generators/QueryMethodEmitter.cs +++ b/src/SqlBound.Generators/QueryMethodEmitter.cs @@ -74,17 +74,20 @@ private static void EmitMethod(QueryMethodModel model, int depth, Action>"; var signatureParameters = new List(); foreach (var parameter in model.Parameters) { var prefix = model.IsExtensionMethod && signatureParameters.Count == 0 ? "this " : ""; + if (model.Shape == ResultShape.Stream && parameter.Kind == ParameterKind.CancellationToken) + { + prefix += "[global::System.Runtime.CompilerServices.EnumeratorCancellation] "; + } + signatureParameters.Add($"{prefix}{parameter.TypeText} {parameter.Name}"); } - line(depth, $"{model.Accessibility} static async partial {returnType} " + line(depth, $"{model.Accessibility} static async partial {model.ReturnTypeText} " + $"{model.MethodName}({string.Join(", ", signatureParameters)})"); line(depth, "{"); line(depth + 1, $"if ({connection} is null)"); @@ -119,39 +122,186 @@ private static void EmitMethod(QueryMethodModel model, int depth, Action line, string cancellationToken) + { line(depth + 2, "global::System.Data.Common.DbDataReader __reader = " + $"await __command.ExecuteReaderAsync({cancellationToken}).ConfigureAwait(false);"); line(depth + 2, "try"); line(depth + 2, "{"); - for (var i = 0; i < model.Columns.Count; i++) + if (model.ElementKind == ResultElementKind.Row) { - line(depth + 3, $"int __ordinal{i} = __reader.GetOrdinal(\"{model.Columns[i].Name}\");"); + for (var i = 0; i < model.Columns.Count; i++) + { + line(depth + 3, $"int __ordinal{i} = __reader.GetOrdinal(\"{model.Columns[i].Name}\");"); + } } - line(depth + 3, $"global::System.Collections.Generic.List<{model.RowTypeText}> __rows = new();"); - line(depth + 3, $"while (await __reader.ReadAsync({cancellationToken}).ConfigureAwait(false))"); - line(depth + 3, "{"); - line(depth + 4, $"__rows.Add(new {model.RowTypeText}("); - for (var i = 0; i < model.Columns.Count; i++) + switch (model.Shape, model.ElementKind) { - var terminator = i == model.Columns.Count - 1 ? "));" : ","; - line(depth + 5, ReadColumn(model.Columns[i], i) + terminator); + case (ResultShape.RowList, ResultElementKind.Row): + EmitRowListRead(model, depth + 3, line, cancellationToken); + break; + case (ResultShape.RowList, ResultElementKind.Scalar): + EmitScalarListRead(model, depth + 3, line, cancellationToken); + break; + case (ResultShape.SingleRow, ResultElementKind.Row): + EmitSingleRead(model, depth + 3, line, cancellationToken, optional: false); + break; + case (ResultShape.OptionalRow, ResultElementKind.Row): + EmitSingleRead(model, depth + 3, line, cancellationToken, optional: true); + break; + case (ResultShape.SingleRow, ResultElementKind.Scalar): + EmitScalarSingleRead(model, depth + 3, line, cancellationToken, optional: false); + break; + case (ResultShape.OptionalRow, ResultElementKind.Scalar): + EmitScalarSingleRead(model, depth + 3, line, cancellationToken, optional: true); + break; + case (ResultShape.Stream, _): + EmitStreamRead(model, depth + 3, line, cancellationToken); + break; + default: + throw new InvalidOperationException( + $"Unknown result shape '{model.Shape}'/'{model.ElementKind}'."); } - line(depth + 3, "}"); - line(depth + 3, ""); - line(depth + 3, "return __rows;"); line(depth + 2, "}"); line(depth + 2, "finally"); line(depth + 2, "{"); line(depth + 3, "await __reader.DisposeAsync().ConfigureAwait(false);"); line(depth + 2, "}"); - line(depth + 1, "}"); - line(depth + 1, "finally"); - line(depth + 1, "{"); - line(depth + 2, "await __command.DisposeAsync().ConfigureAwait(false);"); - line(depth + 1, "}"); + } + + private static void EmitRowListRead( + QueryMethodModel model, int depth, Action line, string cancellationToken) + { + line(depth, $"global::System.Collections.Generic.List<{model.RowTypeText}> __rows = new();"); + line(depth, $"while (await __reader.ReadAsync({cancellationToken}).ConfigureAwait(false))"); + line(depth, "{"); + EmitRowConstruction(model, depth + 1, line, "__rows.Add(", ");"); + line(depth, "}"); + line(depth, ""); + line(depth, "return __rows;"); + } + + private static void EmitSingleRead( + QueryMethodModel model, int depth, Action line, string cancellationToken, bool optional) + { + line(depth, $"if (!await __reader.ReadAsync({cancellationToken}).ConfigureAwait(false))"); + line(depth, "{"); + line(depth + 1, optional + ? "return null;" + : "throw new global::System.InvalidOperationException(" + + "\"The query returned no rows but exactly one was expected.\");"); + line(depth, "}"); + line(depth, ""); + EmitRowConstruction(model, depth, line, $"{model.RowTypeText} __row = ", ";"); + line(depth, $"if (await __reader.ReadAsync({cancellationToken}).ConfigureAwait(false))"); + line(depth, "{"); + line(depth + 1, "throw new global::System.InvalidOperationException(" + + $"\"The query returned more than one row but {(optional ? "at most" : "exactly")} one was expected.\");"); + line(depth, "}"); + line(depth, ""); + line(depth, "return __row;"); + } + + private static void EmitStreamRead( + QueryMethodModel model, int depth, Action line, string cancellationToken) + { + line(depth, $"while (await __reader.ReadAsync({cancellationToken}).ConfigureAwait(false))"); + line(depth, "{"); + if (model.ElementKind == ResultElementKind.Scalar) + { + line(depth + 1, $"yield return {ReadScalar(model.Columns[0])};"); + } + else + { + EmitRowConstruction(model, depth + 1, line, "yield return ", ";"); + } + + line(depth, "}"); + } + + private static void EmitScalarListRead( + QueryMethodModel model, int depth, Action line, string cancellationToken) + { + line(depth, $"global::System.Collections.Generic.List<{model.RowTypeText}> __rows = new();"); + line(depth, $"while (await __reader.ReadAsync({cancellationToken}).ConfigureAwait(false))"); + line(depth, "{"); + line(depth + 1, $"__rows.Add({ReadScalar(model.Columns[0])});"); + line(depth, "}"); + line(depth, ""); + line(depth, "return __rows;"); + } + + private static void EmitScalarSingleRead( + QueryMethodModel model, int depth, Action line, string cancellationToken, bool optional) + { + line(depth, $"if (!await __reader.ReadAsync({cancellationToken}).ConfigureAwait(false))"); + line(depth, "{"); + line(depth + 1, optional + ? "return null;" + : "throw new global::System.InvalidOperationException(" + + "\"The query returned no rows but a scalar value was expected.\");"); line(depth, "}"); + line(depth, ""); + line(depth, $"return {ReadScalar(model.Columns[0])};"); + } + + private static string ReadScalar(ColumnModel column) + { + var read = $"__reader.{column.GetterInvocation}(0)"; + if (column.IsNullable) + { + return $"__reader.IsDBNull(0) ? default({column.TypeText}) : {read}"; + } + + return "__reader.IsDBNull(0) " + + "? throw new global::System.InvalidOperationException(" + + $"\"The scalar result is NULL but maps to non-nullable '{column.TypeText}'.\") " + + $": {read}"; + } + + private static void EmitRowConstruction( + QueryMethodModel model, int depth, Action line, string prefix, string suffix) + { + if (model.RowMapping == RowMappingKind.Properties) + { + line(depth, $"{prefix}new {model.RowTypeText}"); + line(depth, "{"); + for (var i = 0; i < model.Columns.Count; i++) + { + line(depth + 1, $"{model.Columns[i].Name} = {ReadColumn(model.Columns[i], i)},"); + } + + line(depth, "}" + suffix); + return; + } + + line(depth, $"{prefix}new {model.RowTypeText}("); + for (var i = 0; i < model.Columns.Count; i++) + { + var terminator = i == model.Columns.Count - 1 ? ")" + suffix : ","; + line(depth + 1, ReadColumn(model.Columns[i], i) + terminator); + } } private static string ReadColumn(ColumnModel column, int index) diff --git a/src/SqlBound.Generators/QueryMethodModel.cs b/src/SqlBound.Generators/QueryMethodModel.cs index 0f3a22f..bd1639e 100644 --- a/src/SqlBound.Generators/QueryMethodModel.cs +++ b/src/SqlBound.Generators/QueryMethodModel.cs @@ -12,10 +12,41 @@ internal sealed record QueryMethodModel( string MethodName, bool IsExtensionMethod, string CommandText, + string ReturnTypeText, + ResultShape Shape, + ResultElementKind ElementKind, + RowMappingKind RowMapping, string RowTypeText, EquatableArray Columns, EquatableArray Parameters); +/// The result shape a query method's return type declares, driving body emission. +internal enum ResultShape +{ + RowList, + SingleRow, + OptionalRow, + Stream, + Execute, + ExecuteDiscard, +} + +/// How row columns reach the row instance: through the single parameterized public +/// constructor, or through settable properties on a parameterless-constructible type. +internal enum RowMappingKind +{ + Constructor, + Properties, +} + +/// Whether the result element is a constructor-mapped row or a first-column scalar. +/// For scalars, Columns holds a single nameless entry describing the conversion. +internal enum ResultElementKind +{ + Row, + Scalar, +} + /// One type in the (outermost-first) declaration chain wrapping the query method. internal sealed record ContainingTypeModel(string Keyword, string Name); diff --git a/src/SqlBound.Generators/QueryMethodParser.cs b/src/SqlBound.Generators/QueryMethodParser.cs index b9a94dc..0177f3c 100644 --- a/src/SqlBound.Generators/QueryMethodParser.cs +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -14,17 +14,31 @@ internal static class QueryMethodParser SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); - public static SqlQueryPipelineResult Parse(GeneratorAttributeSyntaxContext context) + public static SqlQueryPipelineResult Parse(GeneratorAttributeSyntaxContext context, bool isExecute) { var symbol = (IMethodSymbol)context.TargetSymbol; var syntax = (MethodDeclarationSyntax)context.TargetNode; var location = LocationInfo.From(syntax.Identifier.GetLocation()); var compilation = context.SemanticModel.Compilation; + var attributeName = isExecute ? "SqlExecute" : "SqlQuery"; var diagnostics = new List(); void Report(DiagnosticDescriptor descriptor, params string[] args) => diagnostics.Add(new DiagnosticInfo(descriptor, location, new EquatableArray(args))); + var otherAttributeName = isExecute ? "SqlBound.SqlQueryAttribute" : "SqlBound.SqlExecuteAttribute"; + if (symbol.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == otherAttributeName)) + { + // Both pipelines see such a method; only the execute one reports, so the + // diagnostic appears exactly once and no source is generated. + if (isExecute) + { + Report(SqlQueryDiagnostics.MethodMustNotCarryBothAttributes, symbol.Name); + } + + return new SqlQueryPipelineResult(null, new EquatableArray([.. diagnostics])); + } + var constructorArguments = context.Attributes[0].ConstructorArguments; var commandText = constructorArguments.Length == 1 ? constructorArguments[0].Value as string : null; if (string.IsNullOrWhiteSpace(commandText)) @@ -37,17 +51,17 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => && syntax.ExpressionBody is null; if (!isPartialDefinition || symbol.PartialImplementationPart is not null) { - Report(SqlQueryDiagnostics.MethodMustBePartialDefinition, symbol.Name); + Report(SqlQueryDiagnostics.MethodMustBePartialDefinition, symbol.Name, attributeName); } if (!symbol.IsStatic) { - Report(SqlQueryDiagnostics.MethodMustBeStatic, symbol.Name); + Report(SqlQueryDiagnostics.MethodMustBeStatic, symbol.Name, attributeName); } if (symbol.Arity > 0 || ContainingTypeChain(symbol).Any(type => type.Arity > 0)) { - Report(SqlQueryDiagnostics.GenericDeclarationsNotSupported, symbol.Name); + Report(SqlQueryDiagnostics.GenericDeclarationsNotSupported, symbol.Name, attributeName); } var dbConnectionType = compilation.GetTypeByMetadataName("System.Data.Common.DbConnection"); @@ -59,7 +73,7 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => var methodParameters = symbol.Parameters; if (methodParameters.Length == 0 || !DerivesFrom(methodParameters[0].Type, dbConnectionType)) { - Report(SqlQueryDiagnostics.MethodMustTakeDbConnectionFirst, symbol.Name); + Report(SqlQueryDiagnostics.MethodMustTakeDbConnectionFirst, symbol.Name, attributeName); } else { @@ -98,19 +112,121 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => var taskType = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1"); var readOnlyListType = compilation.GetTypeByMetadataName("System.Collections.Generic.IReadOnlyList`1"); ITypeSymbol? rowType = null; - if (symbol.ReturnType is INamedTypeSymbol task - && SymbolEqualityComparer.Default.Equals(task.OriginalDefinition, taskType) - && task.TypeArguments[0] is INamedTypeSymbol list - && SymbolEqualityComparer.Default.Equals(list.OriginalDefinition, readOnlyListType)) + ColumnModel? scalarColumn = null; + var shape = ResultShape.RowList; + var elementKind = ResultElementKind.Row; + if (isExecute) { - rowType = list.TypeArguments[0]; + var plainTaskType = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task"); + if (SymbolEqualityComparer.Default.Equals(symbol.ReturnType, plainTaskType)) + { + shape = ResultShape.ExecuteDiscard; + } + else if (symbol.ReturnType is INamedTypeSymbol executeTask + && SymbolEqualityComparer.Default.Equals(executeTask.OriginalDefinition, taskType) + && executeTask.TypeArguments[0].SpecialType == SpecialType.System_Int32) + { + shape = ResultShape.Execute; + } + else + { + Report(SqlQueryDiagnostics.UnsupportedExecuteReturnType, symbol.Name, symbol.ReturnType.ToDisplayString()); + } + } + else if (symbol.ReturnType is INamedTypeSymbol task + && SymbolEqualityComparer.Default.Equals(task.OriginalDefinition, taskType)) + { + var payload = task.TypeArguments[0]; + if (payload is INamedTypeSymbol list + && SymbolEqualityComparer.Default.Equals(list.OriginalDefinition, readOnlyListType)) + { + shape = ResultShape.RowList; + var listElement = list.TypeArguments[0]; + if (TryGetGetter(listElement, guidType, out var listGetter)) + { + elementKind = ResultElementKind.Scalar; + scalarColumn = new ColumnModel( + string.Empty, + listElement.ToDisplayString(TypeTextFormat), + listGetter!, + IsNullable(listElement)); + } + else + { + rowType = StripAnnotation(listElement); + } + } + else + { + var isOptional = false; + var element = payload; + if (payload is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullablePayload) + { + isOptional = true; + element = nullablePayload.TypeArguments[0]; + } + else if (payload.IsReferenceType && payload.NullableAnnotation == NullableAnnotation.Annotated) + { + isOptional = true; + element = StripAnnotation(payload); + } + + shape = isOptional ? ResultShape.OptionalRow : ResultShape.SingleRow; + if (TryGetGetter(element, guidType, out var scalarGetter)) + { + elementKind = ResultElementKind.Scalar; + scalarColumn = new ColumnModel( + string.Empty, + payload.ToDisplayString(TypeTextFormat), + scalarGetter!, + isOptional); + } + else + { + rowType = element; + } + } + } + else if (symbol.ReturnType is INamedTypeSymbol asyncEnumerable + && SymbolEqualityComparer.Default.Equals( + asyncEnumerable.OriginalDefinition, + compilation.GetTypeByMetadataName("System.Collections.Generic.IAsyncEnumerable`1"))) + { + shape = ResultShape.Stream; + var streamElement = asyncEnumerable.TypeArguments[0]; + if (TryGetGetter(streamElement, guidType, out var streamGetter)) + { + elementKind = ResultElementKind.Scalar; + scalarColumn = new ColumnModel( + string.Empty, + streamElement.ToDisplayString(TypeTextFormat), + streamGetter!, + IsNullable(streamElement)); + } + else + { + rowType = StripAnnotation(streamElement); + } } else { Report(SqlQueryDiagnostics.UnsupportedReturnType, symbol.Name, symbol.ReturnType.ToDisplayString()); } - ColumnModel[] columns = rowType is null ? [] : ParseColumns(rowType, guidType, Report); + var rowMapping = RowMappingKind.Constructor; + ColumnModel[] columns; + if (scalarColumn is not null) + { + columns = [scalarColumn]; + } + else if (rowType is not null) + { + columns = ParseColumns(rowType, guidType, Report, out rowMapping); + } + else + { + columns = []; + } if (diagnostics.Count > 0) { @@ -131,7 +247,11 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => symbol.Name, symbol.IsExtensionMethod, commandText!, - rowType!.ToDisplayString(TypeTextFormat), + symbol.ReturnType.ToDisplayString(TypeTextFormat), + shape, + elementKind, + rowMapping, + scalarColumn?.TypeText ?? rowType?.ToDisplayString(TypeTextFormat) ?? string.Empty, new EquatableArray(columns), new EquatableArray([.. parameters])); return new SqlQueryPipelineResult(model, EquatableArray.Empty); @@ -140,8 +260,10 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => private static ColumnModel[] ParseColumns( ITypeSymbol rowType, INamedTypeSymbol? guidType, - Action report) + Action report, + out RowMappingKind mapping) { + mapping = RowMappingKind.Constructor; var rowTypeText = rowType.ToDisplayString(); if (rowType is not INamedTypeSymbol named @@ -152,17 +274,34 @@ private static ColumnModel[] ParseColumns( return []; } - var constructors = named.InstanceConstructors + var parameterizedConstructors = named.InstanceConstructors .Where(ctor => ctor.DeclaredAccessibility == Accessibility.Public && ctor.Parameters.Length > 0) .ToArray(); - if (constructors.Length != 1) + if (parameterizedConstructors.Length == 1) { - report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); - return []; + return ParseConstructorColumns(parameterizedConstructors[0], rowTypeText, guidType, report); + } + + var hasParameterlessConstructor = named.InstanceConstructors.Any( + ctor => ctor.DeclaredAccessibility == Accessibility.Public && ctor.Parameters.Length == 0); + if (parameterizedConstructors.Length == 0 && hasParameterlessConstructor) + { + mapping = RowMappingKind.Properties; + return ParsePropertyColumns(named, rowTypeText, guidType, report); } + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + private static ColumnModel[] ParseConstructorColumns( + IMethodSymbol constructor, + string rowTypeText, + INamedTypeSymbol? guidType, + Action report) + { var columns = new List(); - foreach (var parameter in constructors[0].Parameters) + foreach (var parameter in constructor.Parameters) { if (!TryGetGetter(parameter.Type, guidType, out var getter)) { @@ -180,6 +319,50 @@ private static ColumnModel[] ParseColumns( return [.. columns]; } + private static ColumnModel[] ParsePropertyColumns( + INamedTypeSymbol rowType, + string rowTypeText, + INamedTypeSymbol? guidType, + Action report) + { + var columns = new List(); + var seenNames = new HashSet(); + for (var type = rowType; type is not null && type.SpecialType != SpecialType.System_Object; type = type.BaseType) + { + foreach (var property in type.GetMembers().OfType()) + { + if (property.IsStatic + || property.IsIndexer + || property.DeclaredAccessibility != Accessibility.Public + || property.SetMethod is not { DeclaredAccessibility: Accessibility.Public } + || !seenNames.Add(property.Name)) + { + continue; + } + + if (!TryGetGetter(property.Type, guidType, out var getter)) + { + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + columns.Add(new ColumnModel( + property.Name, + property.Type.ToDisplayString(TypeTextFormat), + getter!, + IsNullable(property.Type))); + } + } + + if (columns.Count == 0) + { + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + return [.. columns]; + } + private static bool TryGetGetter(ITypeSymbol type, INamedTypeSymbol? guidType, out string? getter) { var underlying = StripNullable(type); @@ -207,6 +390,11 @@ private static bool IsNullable(ITypeSymbol type) => type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } || (type.IsReferenceType && type.NullableAnnotation == NullableAnnotation.Annotated); + private static ITypeSymbol StripAnnotation(ITypeSymbol type) => + type.IsReferenceType && type.NullableAnnotation == NullableAnnotation.Annotated + ? type.WithNullableAnnotation(NullableAnnotation.NotAnnotated) + : type; + private static ITypeSymbol StripNullable(ITypeSymbol type) => type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable ? nullable.TypeArguments[0] diff --git a/src/SqlBound.Generators/SqlQueryDiagnostics.cs b/src/SqlBound.Generators/SqlQueryDiagnostics.cs index fee34db..784c33c 100644 --- a/src/SqlBound.Generators/SqlQueryDiagnostics.cs +++ b/src/SqlBound.Generators/SqlQueryDiagnostics.cs @@ -10,7 +10,7 @@ internal static class SqlQueryDiagnostics public static readonly DiagnosticDescriptor MethodMustBePartialDefinition = new( "SQLB001", "Method must be a partial definition", - "Method '{0}' marked with [SqlQuery] must be a partial method definition with no body and no separate implementation part", + "Method '{0}' marked with [{1}] must be a partial method definition with no body and no separate implementation part", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -18,7 +18,7 @@ internal static class SqlQueryDiagnostics public static readonly DiagnosticDescriptor MethodMustBeStatic = new( "SQLB002", "Method must be static", - "Method '{0}' marked with [SqlQuery] must be static", + "Method '{0}' marked with [{1}] must be static", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -26,7 +26,7 @@ internal static class SqlQueryDiagnostics public static readonly DiagnosticDescriptor MethodMustTakeDbConnectionFirst = new( "SQLB003", "Method must take a DbConnection first", - "Method '{0}' marked with [SqlQuery] must declare a System.Data.Common.DbConnection (or derived) first parameter", + "Method '{0}' marked with [{1}] must declare a System.Data.Common.DbConnection (or derived) first parameter", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -34,7 +34,7 @@ internal static class SqlQueryDiagnostics public static readonly DiagnosticDescriptor UnsupportedReturnType = new( "SQLB004", "Unsupported return type", - "Method '{0}' returns '{1}' but [SqlQuery] methods must return Task> where T is a supported row type", + "Method '{0}' returns '{1}' but [SqlQuery] methods must return Task, Task, Task>, or IAsyncEnumerable where T is a supported row or scalar type", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -42,7 +42,7 @@ internal static class SqlQueryDiagnostics public static readonly DiagnosticDescriptor UnsupportedRowType = new( "SQLB005", "Unsupported row type", - "Row type '{0}' must expose exactly one public constructor with at least one parameter, and every constructor parameter must be of a supported column type", + "Row type '{0}' must map columns through exactly one public constructor with parameters, or through public settable properties on a parameterless-constructible type, and every mapped member must be of a supported column type", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -58,7 +58,7 @@ internal static class SqlQueryDiagnostics public static readonly DiagnosticDescriptor CommandTextMustNotBeEmpty = new( "SQLB007", "Command text must not be empty", - "The [SqlQuery] command text must not be null, empty, or whitespace", + "The command text must not be null, empty, or whitespace", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -66,7 +66,23 @@ internal static class SqlQueryDiagnostics public static readonly DiagnosticDescriptor GenericDeclarationsNotSupported = new( "SQLB008", "Generic declarations are not supported", - "Method '{0}' marked with [SqlQuery] must not be generic or declared within a generic type", + "Method '{0}' marked with [{1}] must not be generic or declared within a generic type", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnsupportedExecuteReturnType = new( + "SQLB009", + "Unsupported execute return type", + "Method '{0}' returns '{1}' but [SqlExecute] methods must return Task (discarding the count) or Task (the number of affected rows)", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MethodMustNotCarryBothAttributes = new( + "SQLB010", + "Method must not carry both attributes", + "Method '{0}' cannot be marked with both [SqlQuery] and [SqlExecute]", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/src/SqlBound.Generators/SqlQueryGenerator.cs b/src/SqlBound.Generators/SqlQueryGenerator.cs index d61eedf..c1a971d 100644 --- a/src/SqlBound.Generators/SqlQueryGenerator.cs +++ b/src/SqlBound.Generators/SqlQueryGenerator.cs @@ -15,24 +15,32 @@ public sealed class SqlQueryGenerator : IIncrementalGenerator /// public void Initialize(IncrementalGeneratorInitializationContext context) { - var results = context.SyntaxProvider.ForAttributeWithMetadataName( + var queries = context.SyntaxProvider.ForAttributeWithMetadataName( "SqlBound.SqlQueryAttribute", static (node, _) => node is MethodDeclarationSyntax, - static (attributeContext, _) => QueryMethodParser.Parse(attributeContext)); + static (attributeContext, _) => QueryMethodParser.Parse(attributeContext, isExecute: false)); - context.RegisterSourceOutput(results, static (outputContext, result) => + var executes = context.SyntaxProvider.ForAttributeWithMetadataName( + "SqlBound.SqlExecuteAttribute", + static (node, _) => node is MethodDeclarationSyntax, + static (attributeContext, _) => QueryMethodParser.Parse(attributeContext, isExecute: true)); + + context.RegisterSourceOutput(queries, static (outputContext, result) => Produce(outputContext, result)); + context.RegisterSourceOutput(executes, static (outputContext, result) => Produce(outputContext, result)); + } + + private static void Produce(SourceProductionContext context, SqlQueryPipelineResult result) + { + foreach (var diagnostic in result.Diagnostics) { - foreach (var diagnostic in result.Diagnostics) - { - outputContext.ReportDiagnostic(diagnostic.CreateDiagnostic()); - } + context.ReportDiagnostic(diagnostic.CreateDiagnostic()); + } - if (result.Method is { } method) - { - outputContext.AddSource( - QueryMethodEmitter.GetHintName(method), - QueryMethodEmitter.EmitSource(method)); - } - }); + if (result.Method is { } method) + { + context.AddSource( + QueryMethodEmitter.GetHintName(method), + QueryMethodEmitter.EmitSource(method)); + } } } diff --git a/src/SqlBound/SqlExecuteAttribute.cs b/src/SqlBound/SqlExecuteAttribute.cs new file mode 100644 index 0000000..3e39c8d --- /dev/null +++ b/src/SqlBound/SqlExecuteAttribute.cs @@ -0,0 +1,15 @@ +namespace SqlBound; + +/// +/// Marks a static partial method whose implementation is emitted by the SqlBound source +/// generator: the method executes as a non-query statement +/// (INSERT/UPDATE/DELETE/DDL), returning the number of affected rows (Task<int>) +/// or discarding it (Task). +/// +/// The SQL statement the generated implementation executes. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SqlExecuteAttribute(string commandText) : Attribute +{ + /// Gets the SQL statement the generated implementation executes. + public string CommandText { get; } = commandText ?? throw new ArgumentNullException(nameof(commandText)); +} diff --git a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs index f28d045..ba66dc8 100644 --- a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs +++ b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs @@ -29,6 +29,388 @@ public static partial Task> GetItemsAsync( } """; + private const string SingleRowSource = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE id = @id")] + public static partial Task GetByIdAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + } + """; + + private const string OptionalRowSource = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE id = @id")] + public static partial Task FindByIdAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + } + """; + + [Fact] + public void Should_EmitStrictSingleReadImplementation_When_ReturnTypeIsTaskOfRow() + { + var outcome = GeneratorHarness.Run(SingleRowSource); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("no rows but exactly one was expected", generated); + Assert.Contains("more than one row", generated); + } + + [Fact] + public void Should_MatchExpectedSource_When_SingleRowQueryIsGenerated() + { + const string expected = """ + // + // Emitted by the SqlBound source generator. Do not edit. + #nullable enable + + namespace App; + + partial class ItemQueries + { + public static async partial global::System.Threading.Tasks.Task GetByIdAsync(global::System.Data.Common.DbConnection connection, int id, global::System.Threading.CancellationToken cancellationToken) + { + if (connection is null) + { + throw new global::System.ArgumentNullException(nameof(connection)); + } + + global::System.Data.Common.DbCommand __command = connection.CreateCommand(); + try + { + __command.CommandText = @"SELECT id, name, price FROM items WHERE id = @id"; + global::System.Data.Common.DbParameter __parameter0 = __command.CreateParameter(); + __parameter0.ParameterName = "@id"; + __parameter0.Value = id; + __command.Parameters.Add(__parameter0); + global::System.Data.Common.DbDataReader __reader = await __command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + try + { + int __ordinal0 = __reader.GetOrdinal("Id"); + int __ordinal1 = __reader.GetOrdinal("Name"); + int __ordinal2 = __reader.GetOrdinal("Price"); + if (!await __reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + throw new global::System.InvalidOperationException("The query returned no rows but exactly one was expected."); + } + + global::App.Item __row = new global::App.Item( + __reader.IsDBNull(__ordinal0) ? throw new global::System.InvalidOperationException("Column 'Id' is NULL but maps to non-nullable 'int'.") : __reader.GetInt32(__ordinal0), + __reader.IsDBNull(__ordinal1) ? throw new global::System.InvalidOperationException("Column 'Name' is NULL but maps to non-nullable 'string'.") : __reader.GetString(__ordinal1), + __reader.IsDBNull(__ordinal2) ? default(decimal?) : __reader.GetDecimal(__ordinal2)); + if (await __reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + throw new global::System.InvalidOperationException("The query returned more than one row but exactly one was expected."); + } + + return __row; + } + finally + { + await __reader.DisposeAsync().ConfigureAwait(false); + } + } + finally + { + await __command.DisposeAsync().ConfigureAwait(false); + } + } + } + + """; + + var outcome = GeneratorHarness.Run(SingleRowSource); + + var generated = Assert.Single(outcome.GeneratedSources); + Assert.Equal("App.ItemQueries.GetByIdAsync.a1de9431.g.cs", generated.HintName); + Assert.Equal(expected, generated.SourceText.ToString()); + } + + [Fact] + public void Should_ReturnNullInsteadOfThrowing_When_ReturnTypeIsTaskOfNullableRow() + { + var outcome = GeneratorHarness.Run(OptionalRowSource); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("return null;", generated); + Assert.Contains("more than one row", generated); + Assert.DoesNotContain("no rows but exactly one was expected", generated); + } + + [Fact] + public void Should_EmitCompilableImplementation_When_OptionalRowTypeIsValueType() + { + const string source = Prelude + """ + public readonly record struct Point(double X, double Y); + + public static partial class PointQueries + { + [SqlQuery("SELECT x, y FROM points WHERE id = @id")] + public static partial Task FindAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + } + + [Fact] + public void Should_EmitFirstColumnScalarRead_When_ReturnTypeIsTaskOfScalar() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT COUNT(*) FROM items")] + public static partial Task CountAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("GetInt32(0)", generated); + Assert.Contains("no rows but a scalar value was expected", generated); + } + + [Fact] + public void Should_ReturnNullOnNoRowsOrDbNull_When_ReturnTypeIsTaskOfNullableScalar() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT MAX(price) FROM items")] + public static partial Task MaxPriceAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("return null;", generated); + Assert.Contains("default(decimal?)", generated); + Assert.Contains("GetDecimal(0)", generated); + } + + [Fact] + public void Should_EmitScalarList_When_ReturnTypeIsTaskOfReadOnlyListOfScalar() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT name FROM items")] + public static partial Task> GetNamesAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("List __rows", generated); + Assert.Contains("GetString(0)", generated); + } + + [Fact] + public void Should_EmitCompilableImplementation_When_ScalarListElementIsNullable() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT price FROM items")] + public static partial Task> GetPricesAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("List __rows", generated); + Assert.Contains("default(decimal?)", generated); + } + + [Fact] + public void Should_EmitAsyncIteratorWithEnumeratorCancellation_When_ReturnTypeIsAsyncEnumerableOfRow() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial IAsyncEnumerable StreamAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("yield return new global::App.Item(", generated); + Assert.Contains("[global::System.Runtime.CompilerServices.EnumeratorCancellation]", generated); + } + + [Fact] + public void Should_EmitCompilableIterator_When_ReturnTypeIsAsyncEnumerableOfScalar() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT name FROM items")] + public static partial IAsyncEnumerable StreamNamesAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("GetString(0)", generated); + Assert.Contains("yield return", generated); + } + + [Fact] + public void Should_EmitCompilableIterator_When_StreamHasNoCancellationToken() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial IAsyncEnumerable StreamAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.DoesNotContain("EnumeratorCancellation", generated); + } + + [Fact] + public void Should_MapSettableProperties_When_RowTypeHasParameterlessConstructor() + { + const string source = Prelude + """ + public sealed class Widget + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public decimal? Price { get; set; } + } + + public static partial class WidgetQueries + { + [SqlQuery("SELECT id, name, price FROM widgets")] + public static partial Task> GetAllAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("new global::App.Widget", generated); + Assert.Contains("Id = ", generated); + Assert.Contains("""GetOrdinal("Name")""", generated); + } + + [Fact] + public void Should_MapInitOnlyProperties_When_RowTypeUsesInitSetters() + { + const string source = Prelude + """ + public sealed record Widget + { + public int Id { get; init; } + public string? Name { get; init; } + } + + public static partial class WidgetQueries + { + [SqlQuery("SELECT id, name FROM widgets")] + public static partial Task> GetAllAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + } + + [Fact] + public void Should_MapInheritedSettableProperties_When_RowTypeDerivesFromBase() + { + const string source = Prelude + """ + public abstract class Entity + { + public int Id { get; set; } + } + + public sealed class Widget : Entity + { + public string Name { get; set; } = ""; + } + + public static partial class WidgetQueries + { + [SqlQuery("SELECT id, name FROM widgets")] + public static partial Task> GetAllAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("""GetOrdinal("Id")""", generated); + Assert.Contains("""GetOrdinal("Name")""", generated); + } + + [Fact] + public void Should_PreferConstructorMapping_When_RowTypeHasBothConstructorAndSettableProperties() + { + const string source = Prelude + """ + public sealed class Widget + { + public Widget(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; set; } + public string Name { get; set; } + } + + public static partial class WidgetQueries + { + [SqlQuery("SELECT id, name FROM widgets")] + public static partial Task> GetAllAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("new global::App.Widget(", generated); + Assert.DoesNotContain("Id = ", generated); + } + [Fact] public void Should_EmitSingleImplementationFile_When_MethodIsWellFormed() { diff --git a/test/SqlBound.Generators.UnitTests/SqlExecuteGeneratorTests.cs b/test/SqlBound.Generators.UnitTests/SqlExecuteGeneratorTests.cs new file mode 100644 index 0000000..acd004f --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/SqlExecuteGeneratorTests.cs @@ -0,0 +1,120 @@ +using Microsoft.CodeAnalysis; + +namespace SqlBound.Generators.UnitTests; + +public class SqlExecuteGeneratorTests +{ + private const string Prelude = """ + using System.Collections.Generic; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + using SqlBound; + + namespace App; + + """; + + [Fact] + public void Should_ReturnRowsAffected_When_ExecuteReturnsTaskOfInt() + { + const string source = Prelude + """ + public static partial class ItemCommands + { + [SqlExecute("DELETE FROM items WHERE category = @category")] + public static partial Task DeleteCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("return await __command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);", generated); + } + + [Fact] + public void Should_DiscardRowsAffected_When_ExecuteReturnsPlainTask() + { + const string source = Prelude + """ + public static partial class ItemCommands + { + [SqlExecute("DELETE FROM items")] + public static partial Task ClearAsync( + DbConnection connection, CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("await __command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);", generated); + Assert.DoesNotContain("return await", generated); + } + + [Fact] + public void Should_BindScalarsAndTransaction_When_ExecuteHasParameters() + { + const string source = Prelude + """ + public static partial class ItemCommands + { + [SqlExecute("UPDATE items SET price = @price WHERE id = @id")] + public static partial Task RepriceAsync( + DbConnection connection, DbTransaction? transaction, int id, decimal price, + CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("""ParameterName = "@id";""", generated); + Assert.Contains("""ParameterName = "@price";""", generated); + Assert.Contains("__command.Transaction = transaction;", generated); + } + + [Fact] + public void Should_ReportSqlb009_When_ExecuteReturnTypeIsUnsupported() + { + const string source = Prelude + """ + public static partial class ItemCommands + { + [SqlExecute("DELETE FROM items")] + public static partial Task ClearAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB009", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb010AndGenerateNothing_When_MethodCarriesBothAttributes() + { + const string source = Prelude + """ + public static partial class ItemCommands + { + [SqlQuery("SELECT COUNT(*) FROM items")] + [SqlExecute("DELETE FROM items")] + public static partial Task ConfusedAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB010", diagnostic.Id); + Assert.Empty(outcome.GeneratedSources); + } + + private static void AssertCompilesClean(GeneratorRunOutcome outcome) + { + Assert.Empty(outcome.GeneratorDiagnostics); + Assert.DoesNotContain(outcome.CompilationDiagnostics, d => d.Severity >= DiagnosticSeverity.Warning); + } +} diff --git a/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs index b25a9dc..4b35df1 100644 --- a/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs +++ b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs @@ -106,13 +106,13 @@ public static partial class ItemQueries } [Fact] - public void Should_ReportSqlb004_When_ReturnTypeIsNotTaskOfReadOnlyList() + public void Should_ReportSqlb004_When_ReturnTypeIsNotASupportedShape() { const string source = Prelude + """ public static partial class ItemQueries { - [SqlQuery("SELECT COUNT(*) FROM items")] - public static partial Task CountItemsAsync(DbConnection connection); + [SqlQuery("DELETE FROM items")] + public static partial Task DeleteAllAsync(DbConnection connection); } """; @@ -141,6 +141,51 @@ public static partial class ItemQueries Assert.Equal("SQLB005", diagnostic.Id); } + [Fact] + public void Should_ReportSqlb005_When_SettablePropertyTypeIsUnsupported() + { + const string source = Prelude + """ + public sealed class Exotic + { + public int Id { get; set; } + public object? Payload { get; set; } + } + + public static partial class ItemQueries + { + [SqlQuery("SELECT id, payload FROM exotics")] + public static partial Task> GetExoticsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB005", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb005_When_RowTypeHasNoMappableMembers() + { + const string source = Prelude + """ + public sealed class Opaque + { + public int Id { get; } + } + + public static partial class ItemQueries + { + [SqlQuery("SELECT id FROM opaques")] + public static partial Task> GetOpaquesAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB005", diagnostic.Id); + } + [Fact] public void Should_ReportSqlb006_When_QueryParameterTypeIsUnsupported() { diff --git a/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs b/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs index cf233f1..68b7fcd 100644 --- a/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs +++ b/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs @@ -5,10 +5,9 @@ namespace SqlBound.IntegrationTests; public class GeneratedQueryTests { - [Fact] - public async Task Should_MaterializeRowsIncludingNulls_When_GeneratedQueryExecutes() + private static async Task OpenSeededConnectionAsync() { - await using var connection = new SqliteConnection("Data Source=:memory:"); + var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(TestContext.Current.CancellationToken); var session = new SqlSession(connection); await session.RunAsync( @@ -17,6 +16,13 @@ await session.RunAsync( await session.RunAsync( "INSERT INTO items (id, name, price, category) VALUES (1, 'hammer', 9.5, 'tools'), (2, 'nails', NULL, 'tools'), (3, 'apple', 0.5, 'food')", cancellationToken: TestContext.Current.CancellationToken); + return connection; + } + + [Fact] + public async Task Should_MaterializeRowsIncludingNulls_When_GeneratedQueryExecutes() + { + await using var connection = await OpenSeededConnectionAsync(); var items = await ItemQueries.GetByCategoryAsync( connection, "tools", TestContext.Current.CancellationToken); @@ -26,6 +32,100 @@ await session.RunAsync( items); } + [Fact] + public async Task Should_ReturnSingleRow_When_ExactlyOneRowMatches() + { + await using var connection = await OpenSeededConnectionAsync(); + + var item = await ItemQueries.GetByIdAsync(connection, 1, TestContext.Current.CancellationToken); + + Assert.Equal(new Item(1, "hammer", 9.5m), item); + } + + [Fact] + public async Task Should_ThrowInvalidOperationException_When_SingleRowQueryMatchesNothing() + { + await using var connection = await OpenSeededConnectionAsync(); + + await Assert.ThrowsAsync( + () => ItemQueries.GetByIdAsync(connection, 99, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_ReturnNull_When_OptionalRowQueryMatchesNothing() + { + await using var connection = await OpenSeededConnectionAsync(); + + var item = await ItemQueries.FindByIdAsync(connection, 99, TestContext.Current.CancellationToken); + + Assert.Null(item); + } + + [Fact] + public async Task Should_ReturnScalarCount_When_ScalarQueryExecutes() + { + await using var connection = await OpenSeededConnectionAsync(); + + var count = await ItemQueries.CountByCategoryAsync( + connection, "tools", TestContext.Current.CancellationToken); + + Assert.Equal(2, count); + } + + [Fact] + public async Task Should_ReturnScalarList_When_QuerySelectsSingleColumn() + { + await using var connection = await OpenSeededConnectionAsync(); + + var names = await ItemQueries.GetNamesByCategoryAsync( + connection, "tools", TestContext.Current.CancellationToken); + + Assert.Equal(["hammer", "nails"], names); + } + + [Fact] + public async Task Should_StreamRows_When_IteratedWithAwaitForeach() + { + await using var connection = await OpenSeededConnectionAsync(); + + var names = new List(); + await foreach (var item in ItemQueries.StreamByCategoryAsync( + connection, "tools", TestContext.Current.CancellationToken)) + { + names.Add(item.Name); + } + + Assert.Equal(["hammer", "nails"], names); + } + + [Fact] + public async Task Should_ReturnRowsAffected_When_ExecuteStatementRuns() + { + await using var connection = await OpenSeededConnectionAsync(); + + var affected = await ItemQueries.DeleteByCategoryAsync( + connection, "tools", TestContext.Current.CancellationToken); + + Assert.Equal(2, affected); + Assert.Equal(0, await ItemQueries.CountByCategoryAsync( + connection, "tools", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_MaterializeThroughProperties_When_RowTypeIsMutableClass() + { + await using var connection = await OpenSeededConnectionAsync(); + + var entities = await ItemEntityQueries.GetByCategoryAsync( + connection, "tools", TestContext.Current.CancellationToken); + + Assert.Equal(2, entities.Count); + Assert.Equal(1, entities[0].Id); + Assert.Equal("hammer", entities[0].Name); + Assert.Equal(9.5m, entities[0].Price); + Assert.Null(entities[1].Price); + } + [Fact] public async Task Should_ReadDapperWrites_When_GeneratedQueryRunsOnSharedConnection() { diff --git a/test/SqlBound.IntegrationTests/ItemEntityQueries.cs b/test/SqlBound.IntegrationTests/ItemEntityQueries.cs new file mode 100644 index 0000000..ac51456 --- /dev/null +++ b/test/SqlBound.IntegrationTests/ItemEntityQueries.cs @@ -0,0 +1,17 @@ +using System.Data.Common; + +namespace SqlBound.IntegrationTests; + +public static partial class ItemEntityQueries +{ + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE category = @category ORDER BY id")] + public static partial Task> GetByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); +} + +public sealed class ItemEntity +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public decimal? Price { get; set; } +} diff --git a/test/SqlBound.IntegrationTests/ItemQueries.cs b/test/SqlBound.IntegrationTests/ItemQueries.cs index 0c7de38..bbbabc0 100644 --- a/test/SqlBound.IntegrationTests/ItemQueries.cs +++ b/test/SqlBound.IntegrationTests/ItemQueries.cs @@ -7,6 +7,30 @@ public static partial class ItemQueries [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE category = @category ORDER BY id")] public static partial Task> GetByCategoryAsync( DbConnection connection, string category, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE id = @id")] + public static partial Task GetByIdAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE id = @id")] + public static partial Task FindByIdAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT COUNT(*) FROM items WHERE category = @category")] + public static partial Task CountByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT name FROM items WHERE category = @category ORDER BY id")] + public static partial Task> GetNamesByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE category = @category ORDER BY id")] + public static partial IAsyncEnumerable StreamByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); + + [SqlExecute("DELETE FROM items WHERE category = @category")] + public static partial Task DeleteByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); } public sealed record Item(int Id, string Name, decimal? Price); diff --git a/test/SqlBound.UnitTests/SqlExecuteAttributeTests.cs b/test/SqlBound.UnitTests/SqlExecuteAttributeTests.cs new file mode 100644 index 0000000..2d1f78a --- /dev/null +++ b/test/SqlBound.UnitTests/SqlExecuteAttributeTests.cs @@ -0,0 +1,35 @@ +namespace SqlBound.UnitTests; + +public class SqlExecuteAttributeTests +{ + [Fact] + public void Should_ExposeCommandText_When_Constructed() + { + var attribute = new SqlExecuteAttribute("DELETE FROM items"); + + Assert.Equal("DELETE FROM items", attribute.CommandText); + } + + [Fact] + public void Should_ThrowArgumentNullException_When_CommandTextIsNull() + { + Assert.Throws("commandText", () => new SqlExecuteAttribute(null!)); + } + + [Fact] + public void Should_TargetMethodsOnlyAndDisallowMultiple_When_AttributeUsageInspected() + { + var usage = (AttributeUsageAttribute)Attribute.GetCustomAttribute( + typeof(SqlExecuteAttribute), typeof(AttributeUsageAttribute))!; + + Assert.Equal(AttributeTargets.Method, usage.ValidOn); + Assert.False(usage.AllowMultiple); + Assert.False(usage.Inherited); + } + + [Fact] + public void Should_BeSealed_When_TypeInspected() + { + Assert.True(typeof(SqlExecuteAttribute).IsSealed); + } +}