From 6eacfeeedc6f4433dfd5602fa047222b4270b8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 15:05:30 +0100 Subject: [PATCH 1/7] Open M5 with version 0.2.0-preview.2 Incrementing the preview suffix per milestone keeps packages built from different milestones of the same phase distinguishable. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From c1c743ae0ac9a16ff50bbc8b7dc3f9f6cf159be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 15:11:20 +0100 Subject: [PATCH 2/7] Add single and optional row shapes (Task, Task) The model gains a ResultShape discriminator and records the declared return type verbatim, so the emitter dispatches per shape instead of assuming a row list. Task reads with strict-single semantics (zero or multiple rows throw, surfacing missing-WHERE bugs), Task returns null on zero rows and still throws on more than one. Optionality comes from Nullable for value-type rows and the nullable annotation for reference-type rows. The M4 row-list golden stays byte-identical through the refactor; a new golden locks the single-row output. Co-Authored-By: Claude Fable 5 --- src/SqlBound.Generators/QueryMethodEmitter.cs | 70 ++++++++-- src/SqlBound.Generators/QueryMethodModel.cs | 10 ++ src/SqlBound.Generators/QueryMethodParser.cs | 46 ++++++- .../SqlQueryDiagnostics.cs | 2 +- .../QueryMethodEmissionTests.cs | 130 ++++++++++++++++++ 5 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/SqlBound.Generators/QueryMethodEmitter.cs b/src/SqlBound.Generators/QueryMethodEmitter.cs index 952ce7c..3258744 100644 --- a/src/SqlBound.Generators/QueryMethodEmitter.cs +++ b/src/SqlBound.Generators/QueryMethodEmitter.cs @@ -74,8 +74,6 @@ private static void EmitMethod(QueryMethodModel model, int depth, Action>"; var signatureParameters = new List(); foreach (var parameter in model.Parameters) @@ -84,7 +82,7 @@ private static void EmitMethod(QueryMethodModel model, int depth, Action __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) { - var terminator = i == model.Columns.Count - 1 ? "));" : ","; - line(depth + 5, ReadColumn(model.Columns[i], i) + terminator); + case ResultShape.RowList: + EmitRowListRead(model, depth + 3, line, cancellationToken); + break; + case ResultShape.SingleRow: + EmitSingleRead(model, depth + 3, line, cancellationToken, optional: false); + break; + case ResultShape.OptionalRow: + EmitSingleRead(model, depth + 3, line, cancellationToken, optional: true); + break; + default: + throw new InvalidOperationException($"Unknown result shape '{model.Shape}'."); } - line(depth + 3, "}"); - line(depth + 3, ""); - line(depth + 3, "return __rows;"); line(depth + 2, "}"); line(depth + 2, "finally"); line(depth + 2, "{"); @@ -154,6 +154,50 @@ private static void EmitMethod(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 EmitRowConstruction( + QueryMethodModel model, int depth, Action line, string prefix, string suffix) + { + 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) { var read = $"__reader.{column.GetterInvocation}(__ordinal{index})"; diff --git a/src/SqlBound.Generators/QueryMethodModel.cs b/src/SqlBound.Generators/QueryMethodModel.cs index 0f3a22f..a0a9480 100644 --- a/src/SqlBound.Generators/QueryMethodModel.cs +++ b/src/SqlBound.Generators/QueryMethodModel.cs @@ -12,10 +12,20 @@ internal sealed record QueryMethodModel( string MethodName, bool IsExtensionMethod, string CommandText, + string ReturnTypeText, + ResultShape Shape, 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, +} + /// 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..736eca4 100644 --- a/src/SqlBound.Generators/QueryMethodParser.cs +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -98,12 +98,43 @@ 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; + var shape = ResultShape.RowList; if (symbol.ReturnType is INamedTypeSymbol task - && SymbolEqualityComparer.Default.Equals(task.OriginalDefinition, taskType) - && task.TypeArguments[0] is INamedTypeSymbol list - && SymbolEqualityComparer.Default.Equals(list.OriginalDefinition, readOnlyListType)) + && SymbolEqualityComparer.Default.Equals(task.OriginalDefinition, taskType)) { - rowType = list.TypeArguments[0]; + var payload = task.TypeArguments[0]; + if (payload is INamedTypeSymbol list + && SymbolEqualityComparer.Default.Equals(list.OriginalDefinition, readOnlyListType)) + { + shape = ResultShape.RowList; + rowType = StripAnnotation(list.TypeArguments[0]); + } + 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); + } + + if (TryGetGetter(element, guidType, out _)) + { + // Scalar shapes arrive in a later M5 commit; until then a scalar payload is unsupported. + Report(SqlQueryDiagnostics.UnsupportedReturnType, symbol.Name, symbol.ReturnType.ToDisplayString()); + } + else + { + shape = isOptional ? ResultShape.OptionalRow : ResultShape.SingleRow; + rowType = element; + } + } } else { @@ -131,6 +162,8 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => symbol.Name, symbol.IsExtensionMethod, commandText!, + symbol.ReturnType.ToDisplayString(TypeTextFormat), + shape, rowType!.ToDisplayString(TypeTextFormat), new EquatableArray(columns), new EquatableArray([.. parameters])); @@ -207,6 +240,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..6017d82 100644 --- a/src/SqlBound.Generators/SqlQueryDiagnostics.cs +++ b/src/SqlBound.Generators/SqlQueryDiagnostics.cs @@ -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, or Task> where T is a supported row type", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs index f28d045..60314e6 100644 --- a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs +++ b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs @@ -29,6 +29,136 @@ 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_EmitSingleImplementationFile_When_MethodIsWellFormed() { From 3a331e645db5e77930b3ab8c0c2c231253825006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 15:15:11 +0100 Subject: [PATCH 3/7] Add scalar shapes for Task, Task, and Task> Element resolution is now orthogonal to the outer shape: a supported scalar type reads the first column directly (ordinal 0, no GetOrdinal), anything else stays a constructor-mapped row. Non-nullable scalars throw on no rows or DBNull, matching ExecuteScalar expectations without the strict-single check row shapes get, since scalar queries are typically aggregates; nullable scalars return null in both cases. Scalar lists fall out of the same resolution, including nullable elements. Co-Authored-By: Claude Fable 5 --- src/SqlBound.Generators/QueryMethodEmitter.cs | 67 +++++++++++++-- src/SqlBound.Generators/QueryMethodModel.cs | 9 +++ src/SqlBound.Generators/QueryMethodParser.cs | 36 +++++++-- .../SqlQueryDiagnostics.cs | 2 +- .../QueryMethodEmissionTests.cs | 81 +++++++++++++++++++ .../SqlQueryGeneratorTests.cs | 6 +- 6 files changed, 183 insertions(+), 18 deletions(-) diff --git a/src/SqlBound.Generators/QueryMethodEmitter.cs b/src/SqlBound.Generators/QueryMethodEmitter.cs index 3258744..25e692f 100644 --- a/src/SqlBound.Generators/QueryMethodEmitter.cs +++ b/src/SqlBound.Generators/QueryMethodEmitter.cs @@ -121,24 +121,37 @@ private static void EmitMethod(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) { diff --git a/src/SqlBound.Generators/QueryMethodModel.cs b/src/SqlBound.Generators/QueryMethodModel.cs index a0a9480..447b766 100644 --- a/src/SqlBound.Generators/QueryMethodModel.cs +++ b/src/SqlBound.Generators/QueryMethodModel.cs @@ -14,6 +14,7 @@ internal sealed record QueryMethodModel( string CommandText, string ReturnTypeText, ResultShape Shape, + ResultElementKind ElementKind, string RowTypeText, EquatableArray Columns, EquatableArray Parameters); @@ -26,6 +27,14 @@ internal enum ResultShape OptionalRow, } +/// 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 736eca4..69d631d 100644 --- a/src/SqlBound.Generators/QueryMethodParser.cs +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -98,7 +98,9 @@ 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; + ColumnModel? scalarColumn = null; var shape = ResultShape.RowList; + var elementKind = ResultElementKind.Row; if (symbol.ReturnType is INamedTypeSymbol task && SymbolEqualityComparer.Default.Equals(task.OriginalDefinition, taskType)) { @@ -107,7 +109,20 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => && SymbolEqualityComparer.Default.Equals(list.OriginalDefinition, readOnlyListType)) { shape = ResultShape.RowList; - rowType = StripAnnotation(list.TypeArguments[0]); + 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 { @@ -124,14 +139,18 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => element = StripAnnotation(payload); } - if (TryGetGetter(element, guidType, out _)) + shape = isOptional ? ResultShape.OptionalRow : ResultShape.SingleRow; + if (TryGetGetter(element, guidType, out var scalarGetter)) { - // Scalar shapes arrive in a later M5 commit; until then a scalar payload is unsupported. - Report(SqlQueryDiagnostics.UnsupportedReturnType, symbol.Name, symbol.ReturnType.ToDisplayString()); + elementKind = ResultElementKind.Scalar; + scalarColumn = new ColumnModel( + string.Empty, + payload.ToDisplayString(TypeTextFormat), + scalarGetter!, + isOptional); } else { - shape = isOptional ? ResultShape.OptionalRow : ResultShape.SingleRow; rowType = element; } } @@ -141,7 +160,9 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => Report(SqlQueryDiagnostics.UnsupportedReturnType, symbol.Name, symbol.ReturnType.ToDisplayString()); } - ColumnModel[] columns = rowType is null ? [] : ParseColumns(rowType, guidType, Report); + ColumnModel[] columns = scalarColumn is not null + ? [scalarColumn] + : rowType is null ? [] : ParseColumns(rowType, guidType, Report); if (diagnostics.Count > 0) { @@ -164,7 +185,8 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => commandText!, symbol.ReturnType.ToDisplayString(TypeTextFormat), shape, - rowType!.ToDisplayString(TypeTextFormat), + elementKind, + scalarColumn?.TypeText ?? rowType!.ToDisplayString(TypeTextFormat), new EquatableArray(columns), new EquatableArray([.. parameters])); return new SqlQueryPipelineResult(model, EquatableArray.Empty); diff --git a/src/SqlBound.Generators/SqlQueryDiagnostics.cs b/src/SqlBound.Generators/SqlQueryDiagnostics.cs index 6017d82..ca08b2c 100644 --- a/src/SqlBound.Generators/SqlQueryDiagnostics.cs +++ b/src/SqlBound.Generators/SqlQueryDiagnostics.cs @@ -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, Task, or Task> where T is a supported row type", + "Method '{0}' returns '{1}' but [SqlQuery] methods must return Task, Task, or Task> where T is a supported row or scalar type", Category, DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs index 60314e6..6428554 100644 --- a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs +++ b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs @@ -159,6 +159,87 @@ public static partial class PointQueries 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_EmitSingleImplementationFile_When_MethodIsWellFormed() { diff --git a/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs index b25a9dc..792bc08 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); } """; From d8438523f9001f03e92dd1ca2eec1b22b742792f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 15:19:56 +0100 Subject: [PATCH 4/7] Add [SqlExecute] for non-query statements Task cannot mean both a scalar SELECT and rows affected, so non-query statements get their own attribute instead of overloading [SqlQuery]. The name avoids [SqlCommand], which would hit CS1614 ambiguity against Microsoft.Data.SqlClient.SqlCommand in consumer code. [SqlExecute] methods return Task (rows affected) or Task (discarded), sharing the parser, parameter binding, and transaction plumbing; SQLB009 rejects other return types and SQLB010 rejects carrying both attributes, reported exactly once even though both pipelines see the method. Usage diagnostics now name the offending attribute in their messages. Co-Authored-By: Claude Fable 5 --- .../AnalyzerReleases.Unshipped.md | 4 +- src/SqlBound.Generators/QueryMethodEmitter.cs | 27 +++- src/SqlBound.Generators/QueryMethodModel.cs | 2 + src/SqlBound.Generators/QueryMethodParser.cs | 46 ++++++- .../SqlQueryDiagnostics.cs | 26 +++- src/SqlBound.Generators/SqlQueryGenerator.cs | 36 ++++-- src/SqlBound/SqlExecuteAttribute.cs | 15 +++ .../SqlExecuteGeneratorTests.cs | 120 ++++++++++++++++++ .../SqlExecuteAttributeTests.cs | 35 +++++ 9 files changed, 278 insertions(+), 33 deletions(-) create mode 100644 src/SqlBound/SqlExecuteAttribute.cs create mode 100644 test/SqlBound.Generators.UnitTests/SqlExecuteGeneratorTests.cs create mode 100644 test/SqlBound.UnitTests/SqlExecuteAttributeTests.cs 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 25e692f..6cbbbf9 100644 --- a/src/SqlBound.Generators/QueryMethodEmitter.cs +++ b/src/SqlBound.Generators/QueryMethodEmitter.cs @@ -117,6 +117,27 @@ 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"); @@ -159,12 +180,6 @@ private static void EmitMethod(QueryMethodModel model, int depth, ActionWhether the result element is a constructor-mapped row or a first-column scalar. diff --git a/src/SqlBound.Generators/QueryMethodParser.cs b/src/SqlBound.Generators/QueryMethodParser.cs index 69d631d..b5d8127 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 { @@ -101,7 +115,25 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => ColumnModel? scalarColumn = null; var shape = ResultShape.RowList; var elementKind = ResultElementKind.Row; - if (symbol.ReturnType is INamedTypeSymbol task + if (isExecute) + { + 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]; @@ -186,7 +218,7 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => symbol.ReturnType.ToDisplayString(TypeTextFormat), shape, elementKind, - scalarColumn?.TypeText ?? rowType!.ToDisplayString(TypeTextFormat), + scalarColumn?.TypeText ?? rowType?.ToDisplayString(TypeTextFormat) ?? string.Empty, new EquatableArray(columns), new EquatableArray([.. parameters])); return new SqlQueryPipelineResult(model, EquatableArray.Empty); diff --git a/src/SqlBound.Generators/SqlQueryDiagnostics.cs b/src/SqlBound.Generators/SqlQueryDiagnostics.cs index ca08b2c..40eb236 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); @@ -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/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.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); + } +} From 552bf55bfe8741c3f55cdb194061e0ad03666fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 15:22:06 +0100 Subject: [PATCH 5/7] Add streaming shape via IAsyncEnumerable async iterators IAsyncEnumerable methods emit as async iterators that yield each row (or first-column scalar) as it is read, so consumers can process large result sets without buffering; command and reader disposal ride the iterator finally blocks. The generated implementation part decorates the CancellationToken parameter with [EnumeratorCancellation] so WithCancellation flows the token; the compiler merges the attribute across partial parts, which the warning-free compile tests confirm. Co-Authored-By: Claude Fable 5 --- src/SqlBound.Generators/QueryMethodEmitter.cs | 25 ++++++++ src/SqlBound.Generators/QueryMethodModel.cs | 1 + src/SqlBound.Generators/QueryMethodParser.cs | 21 +++++++ .../SqlQueryDiagnostics.cs | 2 +- .../QueryMethodEmissionTests.cs | 58 +++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/SqlBound.Generators/QueryMethodEmitter.cs b/src/SqlBound.Generators/QueryMethodEmitter.cs index 6cbbbf9..9371c47 100644 --- a/src/SqlBound.Generators/QueryMethodEmitter.cs +++ b/src/SqlBound.Generators/QueryMethodEmitter.cs @@ -79,6 +79,11 @@ private static void EmitMethod(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) { diff --git a/src/SqlBound.Generators/QueryMethodModel.cs b/src/SqlBound.Generators/QueryMethodModel.cs index 8d27998..56d3b28 100644 --- a/src/SqlBound.Generators/QueryMethodModel.cs +++ b/src/SqlBound.Generators/QueryMethodModel.cs @@ -25,6 +25,7 @@ internal enum ResultShape RowList, SingleRow, OptionalRow, + Stream, Execute, ExecuteDiscard, } diff --git a/src/SqlBound.Generators/QueryMethodParser.cs b/src/SqlBound.Generators/QueryMethodParser.cs index b5d8127..ef818d5 100644 --- a/src/SqlBound.Generators/QueryMethodParser.cs +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -187,6 +187,27 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => } } } + 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()); diff --git a/src/SqlBound.Generators/SqlQueryDiagnostics.cs b/src/SqlBound.Generators/SqlQueryDiagnostics.cs index 40eb236..7967082 100644 --- a/src/SqlBound.Generators/SqlQueryDiagnostics.cs +++ b/src/SqlBound.Generators/SqlQueryDiagnostics.cs @@ -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, Task, or Task> where T is a supported row or scalar 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); diff --git a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs index 6428554..ad5c4bf 100644 --- a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs +++ b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs @@ -240,6 +240,64 @@ public static partial class ItemQueries 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_EmitSingleImplementationFile_When_MethodIsWellFormed() { From 1a10def35368fb1569cc8e9a692f730caf111c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 15:25:11 +0100 Subject: [PATCH 6/7] Add property-mapped row types via object initializers Row types without a parameterized constructor can now map columns through public settable (set/init) properties, including inherited ones, on a parameterless-constructible type, emitted as an object initializer. A single parameterized public constructor still takes precedence, so positional records keep constructor mapping. All settable properties must be supported column types: silently skipping an unmappable property would hide bugs, so SQLB005 rejects it. Co-Authored-By: Claude Fable 5 --- src/SqlBound.Generators/QueryMethodEmitter.cs | 13 ++ src/SqlBound.Generators/QueryMethodModel.cs | 9 ++ src/SqlBound.Generators/QueryMethodParser.cs | 93 ++++++++++++-- .../SqlQueryDiagnostics.cs | 2 +- .../QueryMethodEmissionTests.cs | 113 ++++++++++++++++++ .../SqlQueryGeneratorTests.cs | 45 +++++++ 6 files changed, 265 insertions(+), 10 deletions(-) diff --git a/src/SqlBound.Generators/QueryMethodEmitter.cs b/src/SqlBound.Generators/QueryMethodEmitter.cs index 9371c47..50dda79 100644 --- a/src/SqlBound.Generators/QueryMethodEmitter.cs +++ b/src/SqlBound.Generators/QueryMethodEmitter.cs @@ -283,6 +283,19 @@ private static string ReadScalar(ColumnModel column) 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++) { diff --git a/src/SqlBound.Generators/QueryMethodModel.cs b/src/SqlBound.Generators/QueryMethodModel.cs index 56d3b28..bd1639e 100644 --- a/src/SqlBound.Generators/QueryMethodModel.cs +++ b/src/SqlBound.Generators/QueryMethodModel.cs @@ -15,6 +15,7 @@ internal sealed record QueryMethodModel( string ReturnTypeText, ResultShape Shape, ResultElementKind ElementKind, + RowMappingKind RowMapping, string RowTypeText, EquatableArray Columns, EquatableArray Parameters); @@ -30,6 +31,14 @@ internal enum ResultShape 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 diff --git a/src/SqlBound.Generators/QueryMethodParser.cs b/src/SqlBound.Generators/QueryMethodParser.cs index ef818d5..0177f3c 100644 --- a/src/SqlBound.Generators/QueryMethodParser.cs +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -213,9 +213,20 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => Report(SqlQueryDiagnostics.UnsupportedReturnType, symbol.Name, symbol.ReturnType.ToDisplayString()); } - ColumnModel[] columns = scalarColumn is not null - ? [scalarColumn] - : 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) { @@ -239,6 +250,7 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => symbol.ReturnType.ToDisplayString(TypeTextFormat), shape, elementKind, + rowMapping, scalarColumn?.TypeText ?? rowType?.ToDisplayString(TypeTextFormat) ?? string.Empty, new EquatableArray(columns), new EquatableArray([.. parameters])); @@ -248,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 @@ -260,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)) { @@ -288,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); diff --git a/src/SqlBound.Generators/SqlQueryDiagnostics.cs b/src/SqlBound.Generators/SqlQueryDiagnostics.cs index 7967082..784c33c 100644 --- a/src/SqlBound.Generators/SqlQueryDiagnostics.cs +++ b/src/SqlBound.Generators/SqlQueryDiagnostics.cs @@ -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); diff --git a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs index ad5c4bf..ba66dc8 100644 --- a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs +++ b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs @@ -298,6 +298,119 @@ public static partial class ItemQueries 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/SqlQueryGeneratorTests.cs b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs index 792bc08..4b35df1 100644 --- a/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs +++ b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs @@ -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() { From b8fa779abffce9b4f8cc0c03a05dcfb6c44d21d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 15:27:04 +0100 Subject: [PATCH 7/7] Prove every query shape end to end on SQLite Integration tests exercise the full M5 matrix against a real in-memory SQLite database: strict single row (hit and miss), optional row miss, scalar count, scalar list, await-foreach streaming, [SqlExecute] rows affected, and property-mapped mutable rows including the NULL-to-null path. The streaming method also validates in a real consumer-shaped build (TreatWarningsAsErrors) that [EnumeratorCancellation] emitted on the implementation part merges cleanly across partial declarations. Co-Authored-By: Claude Fable 5 --- .../GeneratedQueryTests.cs | 106 +++++++++++++++++- .../ItemEntityQueries.cs | 17 +++ test/SqlBound.IntegrationTests/ItemQueries.cs | 24 ++++ 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 test/SqlBound.IntegrationTests/ItemEntityQueries.cs 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);