From 6e116d3bfb75290c6a0e93d339974e130e2e6704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 11:56:03 +0100 Subject: [PATCH 1/7] Open Phase 2 with version 0.2.0-preview.1 Phase 2 (Codegen) gets its own minor version line per the semver convention: each phase owns a minor, carrying a prerelease suffix while the phase is in active development. The suffix drops to a clean 0.2.0 in the commit that closes the phase and gets tagged. 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 26c121d..12e67d9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.1.0 + 0.2.0-preview.1 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound From da7c77f75a2216458d7bea87f6949113b90d85b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 11:57:04 +0100 Subject: [PATCH 2/7] Accept ADR 0002: signature-driven codegen The generator emits materialization code purely from the [SqlQuery] attribute and the declared partial method signature, never from .sqlbound/ snapshots. The roadmap forces this (codegen precedes verification, so no snapshot infrastructure exists when M4 is built), and it preserves ADR 0001''s DX stance that missing or stale snapshots degrade verification but never break the build. Snapshot-informed optimization remains a possible future amendment, not a commitment. Co-Authored-By: Claude Fable 5 --- .../0002-generator-snapshot-consumption.md | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/adr/0002-generator-snapshot-consumption.md b/docs/adr/0002-generator-snapshot-consumption.md index 8567ccb..d74f2af 100644 --- a/docs/adr/0002-generator-snapshot-consumption.md +++ b/docs/adr/0002-generator-snapshot-consumption.md @@ -1,8 +1,8 @@ -# 2. Whether the source generator consumes `.sqlbound/` snapshots +# 2. The source generator emits code from the method signature, not from `.sqlbound/` snapshots ## Status -Proposed — to be decided before M4 (row materialization) starts. +Accepted ## Context @@ -21,8 +21,48 @@ emitting `DbDataReader` materialization code. Two coherent designs exist: ## Decision -Not yet taken. To be resolved when M4 is planned, informed by generator prototyping. +**Signature-driven codegen.** The generator's only inputs are the `[SqlQuery]` attribute and the +declared C# partial method signature; it never reads `.sqlbound/` snapshots. Three reasons: + +1. **The roadmap forces it.** Codegen (Phase 2) deliberately precedes verification (Phase 3) + because the generator defines the shapes the verifier checks. When row materialization (M4) is + built, no introspection, no `prepare` step, and no snapshot format exist yet — snapshot-driven + codegen is unimplementable until M7–M9. Choosing it would invert the phase order. +2. **It matches ADR 0001's DX stance.** The load-bearing decision there was that stale or missing + verification degrades gracefully instead of breaking the inner loop. Snapshot-driven codegen + re-couples every build to snapshot freshness — the exact failure mode ADR 0001 exists to avoid. +3. **The runtime cost is modest and recoverable.** Generated code resolves column ordinals once + via `GetOrdinal` before the read loop; per-row access is still straight-line typed getter + calls — no reflection, no IL emit. If profiling (M6) shows ordinal resolution matters, + snapshot-*informed* optimization can be layered on later without changing the public API (see + Consequences). ## Consequences -Pending decision. +**Positive** + +- The build never depends on snapshot freshness; `.sqlbound/` remains purely a verification input, + keeping one mental model: generator = code shape, analyzer = correctness against the database. +- The generator stays a fast, deterministic function of the compilation alone, with no + `AdditionalFiles` coupling or cross-artifact cache invalidation concerns. +- Phase 2 can ship materialization end to end with zero verification infrastructure. + +**Negative / trade-offs** + +- A column/property mismatch (name or type) surfaces at **runtime** until Phase 3's analyzer + catches it at build time against the snapshots. This is the accepted trade-off; it is the + analyzer's job to close, not the generator's. +- Ordinal resolution happens once per query execution rather than being baked in at compile time. + +**Possible future amendment (explicitly not a commitment)** + +- If M6 benchmarks show `GetOrdinal` resolution to be a measurable cost, the generator may accept + snapshot metadata as an *optional* optimization input — falling back to name-based resolution + when absent, so a missing snapshot still never breaks the build. Any such change amends this + ADR. + +**Interaction with ADR 0001's open point** + +- The analyzer's behavior for a query with *no* snapshot (M8's diagnostic design) is now + unconstrained by codegen: since the build never needs snapshots, a missing one can be anything + from silence to a warning, chosen purely on verification-DX grounds. From d3f2be9a5e865baa5c29882f9e841808565095df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 12:02:45 +0100 Subject: [PATCH 3/7] Add SqlQueryAttribute as the generator entry point [SqlQuery] marks the static partial methods whose implementations the source generator (M4) emits. It lives in the runtime package, not the generator package, so user code compiles against SqlBound alone and the generator remains a pure build-time dependency. Co-Authored-By: Claude Fable 5 --- src/SqlBound/SqlQueryAttribute.cs | 14 ++++++++ .../SqlQueryAttributeTests.cs | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/SqlBound/SqlQueryAttribute.cs create mode 100644 test/SqlBound.UnitTests/SqlQueryAttributeTests.cs diff --git a/src/SqlBound/SqlQueryAttribute.cs b/src/SqlBound/SqlQueryAttribute.cs new file mode 100644 index 0000000..62eed77 --- /dev/null +++ b/src/SqlBound/SqlQueryAttribute.cs @@ -0,0 +1,14 @@ +namespace SqlBound; + +/// +/// Marks a static partial method whose implementation is emitted by the SqlBound source +/// generator: the method executes and materializes the result +/// rows into the method's declared return type with straight-line, reflection-free reader code. +/// +/// The SQL statement the generated implementation executes. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SqlQueryAttribute(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.UnitTests/SqlQueryAttributeTests.cs b/test/SqlBound.UnitTests/SqlQueryAttributeTests.cs new file mode 100644 index 0000000..da85075 --- /dev/null +++ b/test/SqlBound.UnitTests/SqlQueryAttributeTests.cs @@ -0,0 +1,35 @@ +namespace SqlBound.UnitTests; + +public class SqlQueryAttributeTests +{ + [Fact] + public void Should_ExposeCommandText_When_Constructed() + { + var attribute = new SqlQueryAttribute("SELECT id FROM items"); + + Assert.Equal("SELECT id FROM items", attribute.CommandText); + } + + [Fact] + public void Should_ThrowArgumentNullException_When_CommandTextIsNull() + { + Assert.Throws("commandText", () => new SqlQueryAttribute(null!)); + } + + [Fact] + public void Should_TargetMethodsOnlyAndDisallowMultiple_When_AttributeUsageInspected() + { + var usage = (AttributeUsageAttribute)Attribute.GetCustomAttribute( + typeof(SqlQueryAttribute), 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(SqlQueryAttribute).IsSealed); + } +} From a942723e49c26ae887ead966940e9aa635149b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 12:10:57 +0100 Subject: [PATCH 4/7] Add SqlBound.Generators analyzer project and snapshot-test harness The generator targets netstandard2.0 (Roslyn requirement) and packs into analyzers/dotnet/cs with no lib/ folder and no symbol package, so it can never become a runtime dependency of a consumer. Tests drive CSharpGeneratorDriver directly rather than the Microsoft.CodeAnalysis .Testing verifiers, which lag on xunit.v3 support; the harness exposes generated sources, generator diagnostics, and post-generation compilation diagnostics for snapshot assertions. Co-Authored-By: Claude Fable 5 --- sqlbound.slnx | 2 + .../SqlBound.Generators.csproj | 30 +++++++++++ src/SqlBound.Generators/SqlQueryGenerator.cs | 19 +++++++ .../GeneratorHarness.cs | 50 +++++++++++++++++++ .../SqlBound.Generators.UnitTests.csproj | 31 ++++++++++++ .../SqlQueryGeneratorTests.cs | 22 ++++++++ 6 files changed, 154 insertions(+) create mode 100644 src/SqlBound.Generators/SqlBound.Generators.csproj create mode 100644 src/SqlBound.Generators/SqlQueryGenerator.cs create mode 100644 test/SqlBound.Generators.UnitTests/GeneratorHarness.cs create mode 100644 test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj create mode 100644 test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs diff --git a/sqlbound.slnx b/sqlbound.slnx index 83892d5..bc03119 100644 --- a/sqlbound.slnx +++ b/sqlbound.slnx @@ -1,8 +1,10 @@ + + diff --git a/src/SqlBound.Generators/SqlBound.Generators.csproj b/src/SqlBound.Generators/SqlBound.Generators.csproj new file mode 100644 index 0000000..7335ee9 --- /dev/null +++ b/src/SqlBound.Generators/SqlBound.Generators.csproj @@ -0,0 +1,30 @@ + + + + + + netstandard2.0 + true + true + false + true + + false + + $(NoWarn);NU5128 + + + + + + + + + + + + diff --git a/src/SqlBound.Generators/SqlQueryGenerator.cs b/src/SqlBound.Generators/SqlQueryGenerator.cs new file mode 100644 index 0000000..ac65350 --- /dev/null +++ b/src/SqlBound.Generators/SqlQueryGenerator.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; + +namespace SqlBound.Generators; + +/// +/// Incremental source generator that implements [SqlQuery]-annotated static partial +/// methods with straight-line, reflection-free DbDataReader materialization code. Per +/// ADR 0002 the generator is signature-driven: its only inputs are the attribute and the declared +/// method signature — it never reads .sqlbound/ snapshots or performs I/O. +/// +[Generator(LanguageNames.CSharp)] +public sealed class SqlQueryGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // The [SqlQuery] pipeline is registered in the follow-up M4 commits. + } +} diff --git a/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs b/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs new file mode 100644 index 0000000..897ee93 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs @@ -0,0 +1,50 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace SqlBound.Generators.UnitTests; + +/// +/// Runs against an in-memory compilation via the Roslyn +/// , the snapshot-testing entry point for all generator tests. +/// +internal static class GeneratorHarness +{ + private static readonly IReadOnlyList References = CreateReferences(); + + public static GeneratorRunOutcome Run(string source) + { + var compilation = CSharpCompilation.Create( + "SqlBound.Generators.UnitTests.Target", + [CSharpSyntaxTree.ParseText(source)], + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver + .Create(new SqlQueryGenerator()) + .RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out _); + + var result = driver.GetRunResult().Results.Single(); + return new GeneratorRunOutcome( + result.GeneratedSources, + result.Diagnostics, + updatedCompilation.GetDiagnostics()); + } + + private static IReadOnlyList CreateReferences() + { + var trustedAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator); + var references = trustedAssemblies + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToList(); + references.Add(MetadataReference.CreateFromFile(typeof(SqlQueryAttribute).Assembly.Location)); + return references; + } +} + +/// The observable output of one generator run: sources, generator diagnostics, and the +/// diagnostics of the compilation after the generated sources were added to it. +internal sealed record GeneratorRunOutcome( + IReadOnlyList GeneratedSources, + IReadOnlyList GeneratorDiagnostics, + IReadOnlyList CompilationDiagnostics); diff --git a/test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj b/test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj new file mode 100644 index 0000000..2ead018 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj @@ -0,0 +1,31 @@ + + + + + net10.0 + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs new file mode 100644 index 0000000..060e711 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs @@ -0,0 +1,22 @@ +namespace SqlBound.Generators.UnitTests; + +public class SqlQueryGeneratorTests +{ + [Fact] + public void Should_GenerateNothing_When_NoMethodCarriesSqlQueryAttribute() + { + const string source = """ + namespace App; + + public static class NoQueries + { + public static int Add(int left, int right) => left + right; + } + """; + + var outcome = GeneratorHarness.Run(source); + + Assert.Empty(outcome.GeneratedSources); + Assert.Empty(outcome.GeneratorDiagnostics); + } +} From 5913d71466919d773cc8dba6ecc1eaccb3eb5a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 12:20:10 +0100 Subject: [PATCH 5/7] Add [SqlQuery] pipeline with usage diagnostics SQLB001-008 ForAttributeWithMetadataName drives a transform that parses each [SqlQuery] method into a fully value-equatable model (EquatableArray, DiagnosticInfo instead of Diagnostic) so incremental caching works. Invalid usage reports SQLB001-008 as errors: non-partial or already implemented methods, non-static methods, missing DbConnection first parameter, unsupported return/row/parameter types, empty command text, and generic declarations. A valid method yields the emission model; code emission follows in the next commit. Co-Authored-By: Claude Fable 5 --- .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 15 + src/SqlBound.Generators/DiagnosticInfo.cs | 29 ++ src/SqlBound.Generators/EquatableArray.cs | 57 ++++ .../Polyfills/IsExternalInit.cs | 7 + src/SqlBound.Generators/QueryMethodModel.cs | 35 +++ src/SqlBound.Generators/QueryMethodParser.cs | 260 ++++++++++++++++++ .../SqlBound.Generators.csproj | 5 + .../SqlQueryDiagnostics.cs | 73 +++++ src/SqlBound.Generators/SqlQueryGenerator.cs | 16 +- .../SqlQueryPipelineResult.cs | 9 + .../GeneratorHarness.cs | 4 +- .../SqlQueryGeneratorTests.cs | 173 ++++++++++++ 13 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 src/SqlBound.Generators/AnalyzerReleases.Shipped.md create mode 100644 src/SqlBound.Generators/AnalyzerReleases.Unshipped.md create mode 100644 src/SqlBound.Generators/DiagnosticInfo.cs create mode 100644 src/SqlBound.Generators/EquatableArray.cs create mode 100644 src/SqlBound.Generators/Polyfills/IsExternalInit.cs create mode 100644 src/SqlBound.Generators/QueryMethodModel.cs create mode 100644 src/SqlBound.Generators/QueryMethodParser.cs create mode 100644 src/SqlBound.Generators/SqlQueryDiagnostics.cs create mode 100644 src/SqlBound.Generators/SqlQueryPipelineResult.cs diff --git a/src/SqlBound.Generators/AnalyzerReleases.Shipped.md b/src/SqlBound.Generators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..f50bb1f --- /dev/null +++ b/src/SqlBound.Generators/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..2fc436c --- /dev/null +++ b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,15 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------------------------------------------------------------------- +SQLB001 | SqlBound.Usage | Error | [SqlQuery] method must be a partial definition without a body +SQLB002 | SqlBound.Usage | Error | [SqlQuery] method must be static +SQLB003 | SqlBound.Usage | Error | [SqlQuery] method must take a DbConnection (or derived) first parameter +SQLB004 | SqlBound.Usage | Error | [SqlQuery] method must return Task<IReadOnlyList<T>> +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 diff --git a/src/SqlBound.Generators/DiagnosticInfo.cs b/src/SqlBound.Generators/DiagnosticInfo.cs new file mode 100644 index 0000000..b156a75 --- /dev/null +++ b/src/SqlBound.Generators/DiagnosticInfo.cs @@ -0,0 +1,29 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SqlBound.Generators; + +/// +/// A value-equatable stand-in for so diagnostics can travel through the +/// incremental pipeline without breaking model caching ( itself holds +/// non-equatable state such as ). +/// +internal sealed record DiagnosticInfo( + DiagnosticDescriptor Descriptor, + LocationInfo Location, + EquatableArray MessageArgs) +{ + public Diagnostic CreateDiagnostic() => + Diagnostic.Create(Descriptor, Location.ToLocation(), MessageArgs.Cast().ToArray()); +} + +/// The value-equatable pieces of a , for . +internal sealed record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan) +{ + public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan); + + public static LocationInfo From(Location location) => new( + location.SourceTree?.FilePath ?? string.Empty, + location.SourceSpan, + location.GetLineSpan().Span); +} diff --git a/src/SqlBound.Generators/EquatableArray.cs b/src/SqlBound.Generators/EquatableArray.cs new file mode 100644 index 0000000..23f588a --- /dev/null +++ b/src/SqlBound.Generators/EquatableArray.cs @@ -0,0 +1,57 @@ +using System.Collections; + +namespace SqlBound.Generators; + +/// +/// An immutable array with structural (element-wise) equality, required for incremental +/// generator models: pipeline caching compares models by value, and a plain array or +/// ImmutableArray member would defeat it with reference equality. +/// +internal readonly struct EquatableArray(T[] items) : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly T[]? _items = items; + + public static EquatableArray Empty => new([]); + + public int Count => _items?.Length ?? 0; + + public T this[int index] => (_items ?? throw new InvalidOperationException("The array is empty."))[index]; + + public bool Equals(EquatableArray other) + { + var left = _items ?? []; + var right = other._items ?? []; + if (left.Length != right.Length) + { + return false; + } + + for (var i = 0; i < left.Length; i++) + { + if (!left[i].Equals(right[i])) + { + return false; + } + } + + return true; + } + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + var hash = 17; + foreach (var item in _items ?? []) + { + hash = unchecked((hash * 31) + item.GetHashCode()); + } + + return hash; + } + + public IEnumerator GetEnumerator() => ((IEnumerable)(_items ?? [])).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/SqlBound.Generators/Polyfills/IsExternalInit.cs b/src/SqlBound.Generators/Polyfills/IsExternalInit.cs new file mode 100644 index 0000000..b044ab4 --- /dev/null +++ b/src/SqlBound.Generators/Polyfills/IsExternalInit.cs @@ -0,0 +1,7 @@ +// Polyfill: records and init-only setters require this marker type, which netstandard2.0 lacks. +// Analyzer assemblies may carry no package dependencies, so it is declared here instead of +// referencing a polyfill package. + +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit; diff --git a/src/SqlBound.Generators/QueryMethodModel.cs b/src/SqlBound.Generators/QueryMethodModel.cs new file mode 100644 index 0000000..51f1eee --- /dev/null +++ b/src/SqlBound.Generators/QueryMethodModel.cs @@ -0,0 +1,35 @@ +namespace SqlBound.Generators; + +/// +/// The value-equatable model of one valid [SqlQuery] method, extracted from the semantic +/// model in the pipeline's transform step. Emission works exclusively from this model so that +/// incremental caching can skip regeneration whenever the model is unchanged. +/// +internal sealed record QueryMethodModel( + string Namespace, + EquatableArray ContainingTypes, + string Accessibility, + string MethodName, + bool IsExtensionMethod, + string CommandText, + string RowTypeText, + EquatableArray Columns, + EquatableArray Parameters); + +/// One type in the (outermost-first) declaration chain wrapping the query method. +internal sealed record ContainingTypeModel(string Keyword, string Name); + +/// One row-type constructor parameter, read from the result set column of the same name. +internal sealed record ColumnModel(string Name, string TypeText, string GetterInvocation, bool IsNullable); + +/// One parameter of the query method, classified by the role it plays in the generated body. +internal sealed record MethodParameterModel(string Name, string TypeText, ParameterKind Kind); + +/// The role a method parameter plays in the generated implementation. +internal enum ParameterKind +{ + Connection, + Transaction, + CancellationToken, + Scalar, +} diff --git a/src/SqlBound.Generators/QueryMethodParser.cs b/src/SqlBound.Generators/QueryMethodParser.cs new file mode 100644 index 0000000..a913662 --- /dev/null +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -0,0 +1,260 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace SqlBound.Generators; + +/// +/// Turns one [SqlQuery] attribute occurrence into a : +/// a when the method is well-formed, usage diagnostics otherwise. +/// +internal static class QueryMethodParser +{ + private static readonly SymbolDisplayFormat TypeTextFormat = + SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + public static SqlQueryPipelineResult Parse(GeneratorAttributeSyntaxContext context) + { + var symbol = (IMethodSymbol)context.TargetSymbol; + var syntax = (MethodDeclarationSyntax)context.TargetNode; + var location = LocationInfo.From(syntax.Identifier.GetLocation()); + var compilation = context.SemanticModel.Compilation; + var diagnostics = new List(); + + void Report(DiagnosticDescriptor descriptor, params string[] args) => + diagnostics.Add(new DiagnosticInfo(descriptor, location, new EquatableArray(args))); + + var constructorArguments = context.Attributes[0].ConstructorArguments; + var commandText = constructorArguments.Length == 1 ? constructorArguments[0].Value as string : null; + if (string.IsNullOrWhiteSpace(commandText)) + { + Report(SqlQueryDiagnostics.CommandTextMustNotBeEmpty); + } + + var isPartialDefinition = syntax.Modifiers.Any(SyntaxKind.PartialKeyword) + && syntax.Body is null + && syntax.ExpressionBody is null; + if (!isPartialDefinition || symbol.PartialImplementationPart is not null) + { + Report(SqlQueryDiagnostics.MethodMustBePartialDefinition, symbol.Name); + } + + if (!symbol.IsStatic) + { + Report(SqlQueryDiagnostics.MethodMustBeStatic, symbol.Name); + } + + if (symbol.Arity > 0 || ContainingTypeChain(symbol).Any(type => type.Arity > 0)) + { + Report(SqlQueryDiagnostics.GenericDeclarationsNotSupported, symbol.Name); + } + + var dbConnectionType = compilation.GetTypeByMetadataName("System.Data.Common.DbConnection"); + var dbTransactionType = compilation.GetTypeByMetadataName("System.Data.Common.DbTransaction"); + var cancellationTokenType = compilation.GetTypeByMetadataName("System.Threading.CancellationToken"); + var guidType = compilation.GetTypeByMetadataName("System.Guid"); + + var parameters = new List(); + var methodParameters = symbol.Parameters; + if (methodParameters.Length == 0 || !DerivesFrom(methodParameters[0].Type, dbConnectionType)) + { + Report(SqlQueryDiagnostics.MethodMustTakeDbConnectionFirst, symbol.Name); + } + else + { + parameters.Add(new MethodParameterModel( + methodParameters[0].Name, TypeText(methodParameters[0].Type), ParameterKind.Connection)); + } + + for (var i = 1; i < methodParameters.Length; i++) + { + var parameter = methodParameters[i]; + var typeText = TypeText(parameter.Type); + if (parameter.RefKind != RefKind.None) + { + Report(SqlQueryDiagnostics.UnsupportedQueryParameterType, parameter.Name, typeText); + } + else if (i == 1 && DerivesFrom(StripNullable(parameter.Type), dbTransactionType)) + { + parameters.Add(new MethodParameterModel(parameter.Name, typeText, ParameterKind.Transaction)); + } + else if (i == methodParameters.Length - 1 + && SymbolEqualityComparer.Default.Equals(parameter.Type, cancellationTokenType)) + { + parameters.Add(new MethodParameterModel(parameter.Name, typeText, ParameterKind.CancellationToken)); + } + else if (TryGetGetter(parameter.Type, guidType, out _)) + { + parameters.Add(new MethodParameterModel(parameter.Name, typeText, ParameterKind.Scalar)); + } + else + { + Report(SqlQueryDiagnostics.UnsupportedQueryParameterType, parameter.Name, typeText); + } + } + + 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)) + { + rowType = list.TypeArguments[0]; + } + else + { + Report(SqlQueryDiagnostics.UnsupportedReturnType, symbol.Name, symbol.ReturnType.ToDisplayString()); + } + + ColumnModel[] columns = rowType is null ? [] : ParseColumns(rowType, guidType, Report); + + if (diagnostics.Count > 0) + { + return new SqlQueryPipelineResult(null, new EquatableArray([.. diagnostics])); + } + + var containingTypes = ContainingTypeChain(symbol) + .Reverse() + .Select(type => new ContainingTypeModel(TypeKeyword(type), type.Name)) + .ToArray(); + + var model = new QueryMethodModel( + symbol.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : symbol.ContainingNamespace.ToDisplayString(), + new EquatableArray(containingTypes), + AccessibilityText(symbol.DeclaredAccessibility), + symbol.Name, + symbol.IsExtensionMethod, + commandText!, + rowType!.ToDisplayString(TypeTextFormat), + new EquatableArray(columns), + new EquatableArray([.. parameters])); + return new SqlQueryPipelineResult(model, EquatableArray.Empty); + } + + private static ColumnModel[] ParseColumns( + ITypeSymbol rowType, + INamedTypeSymbol? guidType, + Action report) + { + var rowTypeText = rowType.ToDisplayString(); + + if (rowType is not INamedTypeSymbol named + || named.IsAbstract + || named.TypeKind is not (TypeKind.Class or TypeKind.Struct)) + { + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + var constructors = named.InstanceConstructors + .Where(ctor => ctor.DeclaredAccessibility == Accessibility.Public && ctor.Parameters.Length > 0) + .ToArray(); + if (constructors.Length != 1) + { + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + var columns = new List(); + foreach (var parameter in constructors[0].Parameters) + { + if (!TryGetGetter(parameter.Type, guidType, out var getter)) + { + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + columns.Add(new ColumnModel( + parameter.Name, + parameter.Type.ToDisplayString(TypeTextFormat), + getter!, + IsNullable(parameter.Type))); + } + + return [.. columns]; + } + + private static bool TryGetGetter(ITypeSymbol type, INamedTypeSymbol? guidType, out string? getter) + { + var underlying = StripNullable(type); + getter = underlying.SpecialType switch + { + SpecialType.System_Int32 => "GetInt32", + SpecialType.System_Int64 => "GetInt64", + SpecialType.System_Int16 => "GetInt16", + SpecialType.System_Byte => "GetByte", + SpecialType.System_Boolean => "GetBoolean", + SpecialType.System_String => "GetString", + SpecialType.System_Double => "GetDouble", + SpecialType.System_Single => "GetFloat", + SpecialType.System_Decimal => "GetDecimal", + SpecialType.System_DateTime => "GetDateTime", + _ when SymbolEqualityComparer.Default.Equals(underlying, guidType) => "GetGuid", + _ when underlying is IArrayTypeSymbol { IsSZArray: true, ElementType.SpecialType: SpecialType.System_Byte } + => "GetFieldValue", + _ => null, + }; + return getter is not null; + } + + private static bool IsNullable(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + || (type.IsReferenceType && type.NullableAnnotation == NullableAnnotation.Annotated); + + private static ITypeSymbol StripNullable(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : type; + + private static bool DerivesFrom(ITypeSymbol type, INamedTypeSymbol? baseType) + { + if (baseType is null) + { + return false; + } + + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + { + return true; + } + } + + return false; + } + + private static IEnumerable ContainingTypeChain(IMethodSymbol symbol) + { + for (var type = symbol.ContainingType; type is not null; type = type.ContainingType) + { + yield return type; + } + } + + private static string TypeText(ITypeSymbol type) => type.ToDisplayString(TypeTextFormat); + + private static string TypeKeyword(INamedTypeSymbol type) => type switch + { + { IsRecord: true, TypeKind: TypeKind.Struct } => "record struct", + { IsRecord: true } => "record", + { TypeKind: TypeKind.Struct } => "struct", + { TypeKind: TypeKind.Interface } => "interface", + _ => "class", + }; + + private static string AccessibilityText(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "private", + }; +} diff --git a/src/SqlBound.Generators/SqlBound.Generators.csproj b/src/SqlBound.Generators/SqlBound.Generators.csproj index 7335ee9..6839967 100644 --- a/src/SqlBound.Generators/SqlBound.Generators.csproj +++ b/src/SqlBound.Generators/SqlBound.Generators.csproj @@ -23,6 +23,11 @@ + + + + + diff --git a/src/SqlBound.Generators/SqlQueryDiagnostics.cs b/src/SqlBound.Generators/SqlQueryDiagnostics.cs new file mode 100644 index 0000000..fee34db --- /dev/null +++ b/src/SqlBound.Generators/SqlQueryDiagnostics.cs @@ -0,0 +1,73 @@ +using Microsoft.CodeAnalysis; + +namespace SqlBound.Generators; + +/// The SQLB0## descriptors for invalid [SqlQuery] usage reported by the generator. +internal static class SqlQueryDiagnostics +{ + private const string Category = "SqlBound.Usage"; + + 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", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MethodMustBeStatic = new( + "SQLB002", + "Method must be static", + "Method '{0}' marked with [SqlQuery] must be static", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + 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", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + 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", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + 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", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnsupportedQueryParameterType = new( + "SQLB006", + "Unsupported query parameter type", + "Parameter '{0}' of type '{1}' is not supported; query parameters must be supported scalar types, with an optional DbTransaction second parameter and an optional trailing CancellationToken", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CommandTextMustNotBeEmpty = new( + "SQLB007", + "Command text must not be empty", + "The [SqlQuery] command text must not be null, empty, or whitespace", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + 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", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); +} diff --git a/src/SqlBound.Generators/SqlQueryGenerator.cs b/src/SqlBound.Generators/SqlQueryGenerator.cs index ac65350..744424e 100644 --- a/src/SqlBound.Generators/SqlQueryGenerator.cs +++ b/src/SqlBound.Generators/SqlQueryGenerator.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; namespace SqlBound.Generators; @@ -14,6 +15,19 @@ public sealed class SqlQueryGenerator : IIncrementalGenerator /// public void Initialize(IncrementalGeneratorInitializationContext context) { - // The [SqlQuery] pipeline is registered in the follow-up M4 commits. + var results = context.SyntaxProvider.ForAttributeWithMetadataName( + "SqlBound.SqlQueryAttribute", + static (node, _) => node is MethodDeclarationSyntax, + static (attributeContext, _) => QueryMethodParser.Parse(attributeContext)); + + context.RegisterSourceOutput(results, static (outputContext, result) => + { + foreach (var diagnostic in result.Diagnostics) + { + outputContext.ReportDiagnostic(diagnostic.CreateDiagnostic()); + } + + // result.Method emission arrives in the next M4 commit. + }); } } diff --git a/src/SqlBound.Generators/SqlQueryPipelineResult.cs b/src/SqlBound.Generators/SqlQueryPipelineResult.cs new file mode 100644 index 0000000..26ec939 --- /dev/null +++ b/src/SqlBound.Generators/SqlQueryPipelineResult.cs @@ -0,0 +1,9 @@ +namespace SqlBound.Generators; + +/// +/// The outcome of parsing one [SqlQuery] method: either a model to emit from, or the usage +/// diagnostics explaining why no implementation can be generated. +/// +internal sealed record SqlQueryPipelineResult( + QueryMethodModel? Method, + EquatableArray Diagnostics); diff --git a/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs b/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs index 897ee93..51b098a 100644 --- a/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs +++ b/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs @@ -17,7 +17,9 @@ public static GeneratorRunOutcome Run(string source) "SqlBound.Generators.UnitTests.Target", [CSharpSyntaxTree.ParseText(source)], References, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); var driver = CSharpGeneratorDriver .Create(new SqlQueryGenerator()) diff --git a/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs index 060e711..b25a9dc 100644 --- a/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs +++ b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs @@ -2,6 +2,19 @@ namespace SqlBound.Generators.UnitTests; public class SqlQueryGeneratorTests { + private const string Prelude = """ + using System.Collections.Generic; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + using SqlBound; + + namespace App; + + public sealed record Item(int Id, string Name, decimal? Price); + + """; + [Fact] public void Should_GenerateNothing_When_NoMethodCarriesSqlQueryAttribute() { @@ -19,4 +32,164 @@ public static class NoQueries Assert.Empty(outcome.GeneratedSources); Assert.Empty(outcome.GeneratorDiagnostics); } + + [Fact] + public void Should_ReportNoDiagnostics_When_MethodIsWellFormed() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE category = @category")] + public static partial Task> GetItemsAsync( + DbConnection connection, + DbTransaction? transaction, + string category, + CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + Assert.Empty(outcome.GeneratorDiagnostics); + } + + [Fact] + public void Should_ReportSqlb001_When_MethodIsNotPartial() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static Task> GetItemsAsync(DbConnection connection) + => Task.FromResult>([]); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB001", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb002_When_MethodIsNotStatic() + { + const string source = Prelude + """ + public partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public partial Task> GetItemsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB002", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb003_When_FirstParameterIsNotDbConnection() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE category = @category")] + public static partial Task> GetItemsAsync(string category); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB003", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb004_When_ReturnTypeIsNotTaskOfReadOnlyList() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT COUNT(*) FROM items")] + public static partial Task CountItemsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB004", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb005_When_RowTypeConstructorParameterTypeIsUnsupported() + { + const string source = Prelude + """ + public sealed record Exotic(int Id, object Payload); + + 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_ReportSqlb006_When_QueryParameterTypeIsUnsupported() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE category = @category")] + public static partial Task> GetItemsAsync( + DbConnection connection, object category); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB006", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb007_When_CommandTextIsWhitespace() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery(" ")] + public static partial Task> GetItemsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB007", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb008_When_MethodIsGeneric() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial Task> GetItemsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB008", diagnostic.Id); + } } From 3128b3ce0b16b6ae0fe4dae8bd46c4886647ef1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 12:41:16 +0100 Subject: [PATCH 6/7] Emit straight-line DbDataReader materialization for [SqlQuery] methods The generated implementation resolves column ordinals once by constructor-parameter name before the read loop, then materializes each row with typed getter calls: nullable members map DBNull to null, non-nullable members throw a descriptive InvalidOperationException naming the column. Scalars bind as @name parameters (coalescing to DBNull only when the argument can be null), an optional DbTransaction is honored, and a missing CancellationToken parameter falls back to CancellationToken.None. Hint names carry an FNV-1a signature hash so output stays deterministic across processes, unlike string.GetHashCode. Snapshot tests lock the canonical output byte for byte and prove every variant compiles warning-free in a nullable-enabled compilation. Co-Authored-By: Claude Fable 5 --- src/SqlBound.Generators/QueryMethodEmitter.cs | 206 ++++++++++++++++ src/SqlBound.Generators/QueryMethodModel.cs | 6 +- src/SqlBound.Generators/QueryMethodParser.cs | 3 +- src/SqlBound.Generators/SqlQueryGenerator.cs | 7 +- .../QueryMethodEmissionTests.cs | 231 ++++++++++++++++++ 5 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 src/SqlBound.Generators/QueryMethodEmitter.cs create mode 100644 test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs diff --git a/src/SqlBound.Generators/QueryMethodEmitter.cs b/src/SqlBound.Generators/QueryMethodEmitter.cs new file mode 100644 index 0000000..952ce7c --- /dev/null +++ b/src/SqlBound.Generators/QueryMethodEmitter.cs @@ -0,0 +1,206 @@ +using System.Text; + +namespace SqlBound.Generators; + +/// +/// Emits the implementation part of one [SqlQuery] method from its +/// : straight-line DbDataReader code with ordinals resolved +/// once before the read loop, per-column IsDBNull handling, and no reflection. +/// +internal static class QueryMethodEmitter +{ + public static string GetHintName(QueryMethodModel model) + { + var builder = new StringBuilder(); + if (model.Namespace.Length > 0) + { + builder.Append(model.Namespace).Append('.'); + } + + foreach (var type in model.ContainingTypes) + { + builder.Append(type.Name).Append('.'); + } + + return builder + .Append(model.MethodName).Append('.') + .Append(SignatureHash(model)) + .Append(".g.cs") + .ToString(); + } + + public static string EmitSource(QueryMethodModel model) + { + var builder = new StringBuilder(); + builder.Append("// \n"); + builder.Append("// Emitted by the SqlBound source generator. Do not edit.\n"); + builder.Append("#nullable enable\n\n"); + if (model.Namespace.Length > 0) + { + builder.Append("namespace ").Append(model.Namespace).Append(";\n\n"); + } + + void Line(int level, string text) + { + if (text.Length > 0) + { + builder.Append(new string(' ', level * 4)).Append(text); + } + + builder.Append('\n'); + } + + var depth = 0; + foreach (var type in model.ContainingTypes) + { + Line(depth, $"partial {type.Keyword} {type.Name}"); + Line(depth, "{"); + depth++; + } + + EmitMethod(model, depth, Line); + + for (var i = model.ContainingTypes.Count - 1; i >= 0; i--) + { + Line(i, "}"); + } + + return builder.ToString(); + } + + private static void EmitMethod(QueryMethodModel model, int depth, Action line) + { + var connection = ParameterName(model, ParameterKind.Connection); + var transaction = ParameterNameOrNull(model, ParameterKind.Transaction); + var cancellationToken = ParameterNameOrNull(model, ParameterKind.CancellationToken) + ?? "global::System.Threading.CancellationToken.None"; + var returnType = "global::System.Threading.Tasks.Task<" + + $"global::System.Collections.Generic.IReadOnlyList<{model.RowTypeText}>>"; + + var signatureParameters = new List(); + foreach (var parameter in model.Parameters) + { + var prefix = model.IsExtensionMethod && signatureParameters.Count == 0 ? "this " : ""; + signatureParameters.Add($"{prefix}{parameter.TypeText} {parameter.Name}"); + } + + line(depth, $"{model.Accessibility} static async partial {returnType} " + + $"{model.MethodName}({string.Join(", ", signatureParameters)})"); + line(depth, "{"); + line(depth + 1, $"if ({connection} is null)"); + line(depth + 1, "{"); + line(depth + 2, $"throw new global::System.ArgumentNullException(nameof({connection}));"); + line(depth + 1, "}"); + line(depth + 1, ""); + line(depth + 1, $"global::System.Data.Common.DbCommand __command = {connection}.CreateCommand();"); + line(depth + 1, "try"); + line(depth + 1, "{"); + line(depth + 2, $"__command.CommandText = @\"{model.CommandText.Replace("\"", "\"\"")}\";"); + if (transaction is not null) + { + line(depth + 2, $"__command.Transaction = {transaction};"); + } + + var scalarIndex = 0; + foreach (var parameter in model.Parameters) + { + if (parameter.Kind != ParameterKind.Scalar) + { + continue; + } + + var value = parameter.CanBeNull + ? $"(object?){parameter.Name} ?? global::System.DBNull.Value" + : parameter.Name; + line(depth + 2, $"global::System.Data.Common.DbParameter __parameter{scalarIndex} = __command.CreateParameter();"); + line(depth + 2, $"__parameter{scalarIndex}.ParameterName = \"@{parameter.Name}\";"); + line(depth + 2, $"__parameter{scalarIndex}.Value = {value};"); + line(depth + 2, $"__command.Parameters.Add(__parameter{scalarIndex});"); + scalarIndex++; + } + + 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++) + { + 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++) + { + var terminator = i == model.Columns.Count - 1 ? "));" : ","; + line(depth + 5, ReadColumn(model.Columns[i], i) + terminator); + } + + 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, "}"); + line(depth, "}"); + } + + private static string ReadColumn(ColumnModel column, int index) + { + var read = $"__reader.{column.GetterInvocation}(__ordinal{index})"; + if (column.IsNullable) + { + return $"__reader.IsDBNull(__ordinal{index}) ? default({column.TypeText}) : {read}"; + } + + return $"__reader.IsDBNull(__ordinal{index}) " + + "? throw new global::System.InvalidOperationException(" + + $"\"Column '{column.Name}' is NULL but maps to non-nullable '{column.TypeText}'.\") " + + $": {read}"; + } + + private static string ParameterName(QueryMethodModel model, ParameterKind kind) => + ParameterNameOrNull(model, kind) + ?? throw new InvalidOperationException($"The model has no {kind} parameter."); + + private static string? ParameterNameOrNull(QueryMethodModel model, ParameterKind kind) + { + foreach (var parameter in model.Parameters) + { + if (parameter.Kind == kind) + { + return parameter.Name; + } + } + + return null; + } + + private static string SignatureHash(QueryMethodModel model) + { + var signature = new StringBuilder(model.MethodName); + foreach (var parameter in model.Parameters) + { + signature.Append(',').Append(parameter.TypeText); + } + + // FNV-1a: deterministic across processes, unlike string.GetHashCode, so the hint name + // (and with it the generated output) is stable build over build. + var hash = 2166136261u; + foreach (var character in signature.ToString()) + { + hash = unchecked((hash ^ character) * 16777619u); + } + + return hash.ToString("x8"); + } +} diff --git a/src/SqlBound.Generators/QueryMethodModel.cs b/src/SqlBound.Generators/QueryMethodModel.cs index 51f1eee..0f3a22f 100644 --- a/src/SqlBound.Generators/QueryMethodModel.cs +++ b/src/SqlBound.Generators/QueryMethodModel.cs @@ -22,8 +22,10 @@ internal sealed record ContainingTypeModel(string Keyword, string Name); /// One row-type constructor parameter, read from the result set column of the same name. internal sealed record ColumnModel(string Name, string TypeText, string GetterInvocation, bool IsNullable); -/// One parameter of the query method, classified by the role it plays in the generated body. -internal sealed record MethodParameterModel(string Name, string TypeText, ParameterKind Kind); +/// One parameter of the query method, classified by the role it plays in the generated body. +/// is meaningful for scalars only: it decides whether binding +/// coalesces the argument to DBNull.Value. +internal sealed record MethodParameterModel(string Name, string TypeText, ParameterKind Kind, bool CanBeNull = false); /// The role a method parameter plays in the generated implementation. internal enum ParameterKind diff --git a/src/SqlBound.Generators/QueryMethodParser.cs b/src/SqlBound.Generators/QueryMethodParser.cs index a913662..b9a94dc 100644 --- a/src/SqlBound.Generators/QueryMethodParser.cs +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -86,7 +86,8 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => } else if (TryGetGetter(parameter.Type, guidType, out _)) { - parameters.Add(new MethodParameterModel(parameter.Name, typeText, ParameterKind.Scalar)); + var canBeNull = parameter.Type.IsReferenceType || IsNullable(parameter.Type); + parameters.Add(new MethodParameterModel(parameter.Name, typeText, ParameterKind.Scalar, canBeNull)); } else { diff --git a/src/SqlBound.Generators/SqlQueryGenerator.cs b/src/SqlBound.Generators/SqlQueryGenerator.cs index 744424e..d61eedf 100644 --- a/src/SqlBound.Generators/SqlQueryGenerator.cs +++ b/src/SqlBound.Generators/SqlQueryGenerator.cs @@ -27,7 +27,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) outputContext.ReportDiagnostic(diagnostic.CreateDiagnostic()); } - // result.Method emission arrives in the next M4 commit. + if (result.Method is { } method) + { + outputContext.AddSource( + QueryMethodEmitter.GetHintName(method), + QueryMethodEmitter.EmitSource(method)); + } }); } } diff --git a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs new file mode 100644 index 0000000..f28d045 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs @@ -0,0 +1,231 @@ +using Microsoft.CodeAnalysis; + +namespace SqlBound.Generators.UnitTests; + +public class QueryMethodEmissionTests +{ + private const string Prelude = """ + using System.Collections.Generic; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + using SqlBound; + + namespace App; + + public sealed record Item(int Id, string Name, decimal? Price); + + """; + + private const string CanonicalSource = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE category = @category")] + public static partial Task> GetItemsAsync( + DbConnection connection, + DbTransaction? transaction, + string category, + CancellationToken cancellationToken = default); + } + """; + + [Fact] + public void Should_EmitSingleImplementationFile_When_MethodIsWellFormed() + { + var outcome = GeneratorHarness.Run(CanonicalSource); + + var generated = Assert.Single(outcome.GeneratedSources); + Assert.StartsWith("App.ItemQueries.GetItemsAsync.", generated.HintName); + Assert.EndsWith(".g.cs", generated.HintName); + } + + [Fact] + public void Should_EmitCompilableImplementation_When_MethodIsWellFormed() + { + var outcome = GeneratorHarness.Run(CanonicalSource); + + AssertCompilesClean(outcome); + } + + [Fact] + public void Should_MatchExpectedSource_When_CanonicalQueryIsGenerated() + { + 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> GetItemsAsync(global::System.Data.Common.DbConnection connection, global::System.Data.Common.DbTransaction? transaction, string category, 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 category = @category"; + __command.Transaction = transaction; + global::System.Data.Common.DbParameter __parameter0 = __command.CreateParameter(); + __parameter0.ParameterName = "@category"; + __parameter0.Value = (object?)category ?? global::System.DBNull.Value; + __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"); + global::System.Collections.Generic.List __rows = new(); + while (await __reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + __rows.Add(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))); + } + + return __rows; + } + finally + { + await __reader.DisposeAsync().ConfigureAwait(false); + } + } + finally + { + await __command.DisposeAsync().ConfigureAwait(false); + } + } + } + + """; + + var outcome = GeneratorHarness.Run(CanonicalSource); + + var generated = Assert.Single(outcome.GeneratedSources); + Assert.Equal("App.ItemQueries.GetItemsAsync.a9dfc0d7.g.cs", generated.HintName); + Assert.Equal(expected, generated.SourceText.ToString()); + } + + [Fact] + public void Should_ResolveOrdinalsOnceAndGuardNulls_When_RowHasNullableAndNonNullableColumns() + { + var outcome = GeneratorHarness.Run(CanonicalSource); + + var source = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("""GetOrdinal("Id")""", source); + Assert.Contains("""GetOrdinal("Name")""", source); + Assert.Contains("""GetOrdinal("Price")""", source); + Assert.Contains("default(decimal?)", source); + Assert.Contains("InvalidOperationException", source); + } + + [Fact] + public void Should_BindEachScalarParameter_When_MethodHasMultipleScalars() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE name = @name AND id > @minimumId")] + public static partial Task> SearchAsync( + DbConnection connection, string name, int minimumId); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("""ParameterName = "@name";""", generated); + Assert.Contains("""ParameterName = "@minimumId";""", generated); + } + + [Fact] + public void Should_UseCancellationTokenNone_When_MethodHasNoCancellationTokenParameter() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial Task> GetAllAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("CancellationToken.None", generated); + } + + [Fact] + public void Should_OmitTransactionAssignment_When_MethodHasNoTransactionParameter() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial Task> GetAllAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.DoesNotContain(".Transaction =", generated); + } + + [Fact] + public void Should_EmitCompilableImplementation_When_ContainingTypesAreNested() + { + const string source = Prelude + """ + public static partial class Data + { + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial Task> GetAllAsync(DbConnection connection); + } + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources); + Assert.StartsWith("App.Data.ItemQueries.GetAllAsync.", generated.HintName); + } + + [Fact] + public void Should_EmitCompilableImplementation_When_RowUsesAllSupportedColumnTypes() + { + const string source = Prelude + """ + public sealed record Wide( + int A, long B, short C, byte D, bool E, string F, double G, float H, + decimal I, System.DateTime J, System.Guid K, byte[] L, int? M, string? N); + + public static partial class WideQueries + { + [SqlQuery("SELECT * FROM wide")] + public static partial Task> GetAllAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + } + + private static void AssertCompilesClean(GeneratorRunOutcome outcome) + { + Assert.Empty(outcome.GeneratorDiagnostics); + Assert.DoesNotContain(outcome.CompilationDiagnostics, d => d.Severity >= DiagnosticSeverity.Warning); + } +} From 71da29a76c0dc08b9102b016758828f77fdfad66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 12:46:26 +0100 Subject: [PATCH 7/7] Prove the generated query path end to end on SQLite alongside Dapper The integration test project consumes SqlBound.Generators as an analyzer-only project reference (the same shape a NuGet consumer gets from analyzers/dotnet/cs) and executes a generated [SqlQuery] method against a real in-memory SQLite database: rows materialize including the DBNull-to-nullable path, and the generated method reads writes made by Dapper on the same shared connection, extending the M3 coexistence proof to the static query surface. Co-Authored-By: Claude Fable 5 --- .../GeneratedQueryTests.cs | 46 +++++++++++++++++++ test/SqlBound.IntegrationTests/ItemQueries.cs | 12 +++++ .../SqlBound.IntegrationTests.csproj | 2 + 3 files changed, 60 insertions(+) create mode 100644 test/SqlBound.IntegrationTests/GeneratedQueryTests.cs create mode 100644 test/SqlBound.IntegrationTests/ItemQueries.cs diff --git a/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs b/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs new file mode 100644 index 0000000..cf233f1 --- /dev/null +++ b/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs @@ -0,0 +1,46 @@ +using Dapper; +using Microsoft.Data.Sqlite; + +namespace SqlBound.IntegrationTests; + +public class GeneratedQueryTests +{ + [Fact] + public async Task Should_MaterializeRowsIncludingNulls_When_GeneratedQueryExecutes() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + var session = new SqlSession(connection); + await session.RunAsync( + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NULL, category TEXT NOT NULL)", + cancellationToken: TestContext.Current.CancellationToken); + 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); + + var items = await ItemQueries.GetByCategoryAsync( + connection, "tools", TestContext.Current.CancellationToken); + + Assert.Equal( + [new Item(1, "hammer", 9.5m), new Item(2, "nails", null)], + items); + } + + [Fact] + public async Task Should_ReadDapperWrites_When_GeneratedQueryRunsOnSharedConnection() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await connection.ExecuteAsync( + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NULL, category TEXT NOT NULL)"); + await connection.ExecuteAsync( + "INSERT INTO items (id, name, price, category) VALUES (@Id, @Name, @Price, @Category)", + new { Id = 1, Name = "from-dapper", Price = 1.25, Category = "shared" }); + + var items = await ItemQueries.GetByCategoryAsync( + connection, "shared", TestContext.Current.CancellationToken); + + var item = Assert.Single(items); + Assert.Equal(new Item(1, "from-dapper", 1.25m), item); + } +} diff --git a/test/SqlBound.IntegrationTests/ItemQueries.cs b/test/SqlBound.IntegrationTests/ItemQueries.cs new file mode 100644 index 0000000..0c7de38 --- /dev/null +++ b/test/SqlBound.IntegrationTests/ItemQueries.cs @@ -0,0 +1,12 @@ +using System.Data.Common; + +namespace SqlBound.IntegrationTests; + +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); +} + +public sealed record Item(int Id, string Name, decimal? Price); diff --git a/test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj b/test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj index 2bf32a9..3c8d34f 100644 --- a/test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj +++ b/test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj @@ -11,6 +11,8 @@ + +