From dfd561f651cbac4b79b9311971542aab21125dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 21:24:20 +0100 Subject: [PATCH 1/7] Advance the Phase 3 prerelease line for M8 Bump PackageVersion to 0.3.0-preview.2 so M8's verification analyzer work is distinguishable from the M7 preview in package feeds. 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 7653931..ee4f779 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.3.0-preview.1 + 0.3.0-preview.2 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound From 86c0533f894090fdd11ba9047c567819cc57b83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 21:25:09 +0100 Subject: [PATCH 2/7] Expose a symbol-based parser entry point for the analyzer Extract the parsing core out of the GeneratorAttributeSyntaxContext overload so the upcoming verification analyzer can parse an attributed method from its IMethodSymbol without a generator pipeline. Pure refactor; the golden emission tests pin the behavior. Co-Authored-By: Claude Fable 5 --- src/SqlBound.Generators/QueryMethodParser.cs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/SqlBound.Generators/QueryMethodParser.cs b/src/SqlBound.Generators/QueryMethodParser.cs index 0177f3c..9c89590 100644 --- a/src/SqlBound.Generators/QueryMethodParser.cs +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -14,12 +14,22 @@ internal static class QueryMethodParser SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); - public static SqlQueryPipelineResult Parse(GeneratorAttributeSyntaxContext context, bool isExecute) + public static SqlQueryPipelineResult Parse(GeneratorAttributeSyntaxContext context, bool isExecute) => + Parse( + (IMethodSymbol)context.TargetSymbol, + (MethodDeclarationSyntax)context.TargetNode, + context.Attributes[0], + context.SemanticModel.Compilation, + isExecute); + + public static SqlQueryPipelineResult Parse( + IMethodSymbol symbol, + MethodDeclarationSyntax syntax, + AttributeData attribute, + Compilation compilation, + 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(); @@ -39,7 +49,7 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) => return new SqlQueryPipelineResult(null, new EquatableArray([.. diagnostics])); } - var constructorArguments = context.Attributes[0].ConstructorArguments; + var constructorArguments = attribute.ConstructorArguments; var commandText = constructorArguments.Length == 1 ? constructorArguments[0].Value as string : null; if (string.IsNullOrWhiteSpace(commandText)) { From 09c3c50b5bd8b6a81ef8de73818f256012e01fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 21:28:17 +0100 Subject: [PATCH 3/7] Read committed query snapshots without a JSON dependency QuerySnapshot models the analyzer-side view of one .sqlbound/ file and QuerySnapshotReader parses it with a small hand-rolled JSON parser. A library dependency was rejected deliberately: analyzer dependencies must ship bundled and are a known source of version conflicts inside the IDE, while this schema is tiny and written by SqlBound's own prepare step. Unknown fields are ignored for forward compatibility; malformed input returns false so the analyzer can diagnose, not crash. Co-Authored-By: Claude Fable 5 --- src/SqlBound.Generators/QuerySnapshot.cs | 25 ++ .../QuerySnapshotReader.cs | 329 ++++++++++++++++++ .../SqlBound.Generators.csproj | 4 + .../QuerySnapshotReaderTests.cs | 100 ++++++ 4 files changed, 458 insertions(+) create mode 100644 src/SqlBound.Generators/QuerySnapshot.cs create mode 100644 src/SqlBound.Generators/QuerySnapshotReader.cs create mode 100644 test/SqlBound.Generators.UnitTests/QuerySnapshotReaderTests.cs diff --git a/src/SqlBound.Generators/QuerySnapshot.cs b/src/SqlBound.Generators/QuerySnapshot.cs new file mode 100644 index 0000000..3becfed --- /dev/null +++ b/src/SqlBound.Generators/QuerySnapshot.cs @@ -0,0 +1,25 @@ +namespace SqlBound.Generators; + +/// +/// The analyzer-side view of one committed .sqlbound/ snapshot: the database-described +/// metadata for a single command text, as written by the prepare step. Mirrors the +/// provider describe result (M7's QueryDescription) but lives here because the analyzer +/// targets netstandard2.0 and reads only JSON, never provider assemblies. +/// +internal sealed record QuerySnapshot( + string CommandText, + string Provider, + EquatableArray Columns, + EquatableArray Parameters); + +internal sealed record SnapshotColumn( + int Ordinal, + string Name, + string SqlTypeName, + string ClrTypeText, + bool IsNullable); + +internal sealed record SnapshotParameter( + string Name, + string SqlTypeName, + string ClrTypeText); diff --git a/src/SqlBound.Generators/QuerySnapshotReader.cs b/src/SqlBound.Generators/QuerySnapshotReader.cs new file mode 100644 index 0000000..4f57e68 --- /dev/null +++ b/src/SqlBound.Generators/QuerySnapshotReader.cs @@ -0,0 +1,329 @@ +using System.Globalization; +using System.Text; + +namespace SqlBound.Generators; + +/// +/// Reads a committed .sqlbound/ JSON snapshot into a . +/// The parser is hand-rolled rather than a JSON library dependency: analyzer dependencies must +/// ship bundled beside the assembly and are a known source of version conflicts inside the IDE, +/// while this schema is tiny and produced by SqlBound's own prepare step. Unknown fields +/// are ignored for forward compatibility; any malformation returns false, never throws. +/// +internal static class QuerySnapshotReader +{ + public static bool TryRead(string text, out QuerySnapshot? snapshot) + { + snapshot = null; + object? root; + try + { + root = new JsonParser(text).ParseDocument(); + } + catch (FormatException) + { + return false; + } + + if (root is not Dictionary document + || !TryGetString(document, "commandText", out var commandText) + || !TryGetString(document, "provider", out var provider) + || !TryGetArray(document, "columns", out var columnItems) + || !TryGetArray(document, "parameters", out var parameterItems)) + { + return false; + } + + var columns = new SnapshotColumn[columnItems.Count]; + for (var i = 0; i < columnItems.Count; i++) + { + if (columnItems[i] is not Dictionary column + || !TryGetOrdinal(column, out var ordinal) + || !TryGetString(column, "name", out var name) + || !TryGetString(column, "sqlTypeName", out var sqlTypeName) + || !TryGetString(column, "clrTypeText", out var clrTypeText) + || !TryGetBool(column, "isNullable", out var isNullable)) + { + return false; + } + + columns[i] = new SnapshotColumn(ordinal, name, sqlTypeName, clrTypeText, isNullable); + } + + var parameters = new SnapshotParameter[parameterItems.Count]; + for (var i = 0; i < parameterItems.Count; i++) + { + if (parameterItems[i] is not Dictionary parameter + || !TryGetString(parameter, "name", out var name) + || !TryGetString(parameter, "sqlTypeName", out var sqlTypeName) + || !TryGetString(parameter, "clrTypeText", out var clrTypeText)) + { + return false; + } + + parameters[i] = new SnapshotParameter(name, sqlTypeName, clrTypeText); + } + + snapshot = new QuerySnapshot( + commandText, + provider, + new EquatableArray(columns), + new EquatableArray(parameters)); + return true; + } + + private static bool TryGetString(Dictionary map, string key, out string result) + { + if (map.TryGetValue(key, out var value) && value is string text) + { + result = text; + return true; + } + + result = string.Empty; + return false; + } + + private static bool TryGetBool(Dictionary map, string key, out bool result) + { + if (map.TryGetValue(key, out var value) && value is bool flag) + { + result = flag; + return true; + } + + result = false; + return false; + } + + private static bool TryGetOrdinal(Dictionary map, out int result) + { + if (map.TryGetValue("ordinal", out var value) + && value is double number + && number >= 0 + && number <= int.MaxValue + && number == Math.Floor(number)) + { + result = (int)number; + return true; + } + + result = 0; + return false; + } + + private static bool TryGetArray(Dictionary map, string key, out List result) + { + if (map.TryGetValue(key, out var value) && value is List list) + { + result = list; + return true; + } + + result = []; + return false; + } + + private sealed class JsonParser(string text) + { + private readonly string _text = text; + private int _position; + + public object? ParseDocument() + { + var value = ParseValue(); + SkipWhitespace(); + if (_position != _text.Length) + { + throw new FormatException("Trailing content after the JSON document."); + } + + return value; + } + + private object? ParseValue() + { + SkipWhitespace(); + return Peek() switch + { + '{' => ParseObject(), + '[' => ParseArray(), + '"' => ParseString(), + 't' or 'f' => ParseKeyword(), + 'n' => ParseKeyword(), + _ => ParseNumber(), + }; + } + + private Dictionary ParseObject() + { + Expect('{'); + var result = new Dictionary(); + SkipWhitespace(); + if (Peek() == '}') + { + _position++; + return result; + } + + while (true) + { + SkipWhitespace(); + var key = ParseString(); + SkipWhitespace(); + Expect(':'); + result[key] = ParseValue(); + SkipWhitespace(); + var next = Read(); + if (next == '}') + { + return result; + } + + if (next != ',') + { + throw new FormatException("Expected ',' or '}' in an object."); + } + } + } + + private List ParseArray() + { + Expect('['); + var result = new List(); + SkipWhitespace(); + if (Peek() == ']') + { + _position++; + return result; + } + + while (true) + { + result.Add(ParseValue()); + SkipWhitespace(); + var next = Read(); + if (next == ']') + { + return result; + } + + if (next != ',') + { + throw new FormatException("Expected ',' or ']' in an array."); + } + } + } + + private string ParseString() + { + Expect('"'); + var builder = new StringBuilder(); + while (true) + { + var current = Read(); + if (current == '"') + { + return builder.ToString(); + } + + if (current == '\\') + { + var escape = Read(); + builder.Append(escape switch + { + '"' => '"', + '\\' => '\\', + '/' => '/', + 'b' => '\b', + 'f' => '\f', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'u' => ParseUnicodeEscape(), + _ => throw new FormatException($"Unsupported escape '\\{escape}'."), + }); + } + else if (current < ' ') + { + throw new FormatException("Unescaped control character in a string."); + } + else + { + builder.Append(current); + } + } + } + + private char ParseUnicodeEscape() + { + if (_position + 4 > _text.Length) + { + throw new FormatException("Truncated \\u escape."); + } + + var hex = _text.Substring(_position, 4); + _position += 4; + if (!ushort.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var code)) + { + throw new FormatException($"Invalid \\u escape '{hex}'."); + } + + return (char)code; + } + + private object? ParseKeyword() + { + foreach (var (keyword, value) in new (string, object?)[] { ("true", true), ("false", false), ("null", null) }) + { + if (_position + keyword.Length <= _text.Length + && string.CompareOrdinal(_text, _position, keyword, 0, keyword.Length) == 0) + { + _position += keyword.Length; + return value; + } + } + + throw new FormatException("Unrecognized JSON keyword."); + } + + private object ParseNumber() + { + var start = _position; + while (_position < _text.Length && "+-.eE0123456789".IndexOf(_text[_position]) >= 0) + { + _position++; + } + + var token = _text.Substring(start, _position - start); + if (!double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + { + throw new FormatException($"Invalid JSON number '{token}'."); + } + + return number; + } + + private void SkipWhitespace() + { + while (_position < _text.Length && _text[_position] is ' ' or '\t' or '\r' or '\n') + { + _position++; + } + } + + private char Peek() => + _position < _text.Length ? _text[_position] : throw new FormatException("Unexpected end of JSON."); + + private char Read() => + _position < _text.Length ? _text[_position++] : throw new FormatException("Unexpected end of JSON."); + + private void Expect(char expected) + { + if (Read() != expected) + { + throw new FormatException($"Expected '{expected}'."); + } + } + } +} diff --git a/src/SqlBound.Generators/SqlBound.Generators.csproj b/src/SqlBound.Generators/SqlBound.Generators.csproj index 6839967..7a7e54e 100644 --- a/src/SqlBound.Generators/SqlBound.Generators.csproj +++ b/src/SqlBound.Generators/SqlBound.Generators.csproj @@ -23,6 +23,10 @@ + + + + diff --git a/test/SqlBound.Generators.UnitTests/QuerySnapshotReaderTests.cs b/test/SqlBound.Generators.UnitTests/QuerySnapshotReaderTests.cs new file mode 100644 index 0000000..f5761a9 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/QuerySnapshotReaderTests.cs @@ -0,0 +1,100 @@ +namespace SqlBound.Generators.UnitTests; + +public sealed class QuerySnapshotReaderTests +{ + private const string ValidSnapshot = + """ + { + "commandText": "SELECT Id, Name FROM dbo.Items WHERE Id = @id", + "provider": "sqlserver", + "columns": [ + { "ordinal": 0, "name": "Id", "sqlTypeName": "int", "clrTypeText": "int", "isNullable": false }, + { "ordinal": 1, "name": "Name", "sqlTypeName": "nvarchar(50)", "clrTypeText": "string", "isNullable": true } + ], + "parameters": [ + { "name": "id", "sqlTypeName": "int", "clrTypeText": "int" } + ] + } + """; + + [Fact] + public void Should_ReadCommandTextColumnsAndParameters_When_SnapshotIsValid() + { + var read = QuerySnapshotReader.TryRead(ValidSnapshot, out var snapshot); + + Assert.True(read); + Assert.NotNull(snapshot); + Assert.Equal("SELECT Id, Name FROM dbo.Items WHERE Id = @id", snapshot.CommandText); + Assert.Equal("sqlserver", snapshot.Provider); + Assert.Equal( + [ + new SnapshotColumn(0, "Id", "int", "int", IsNullable: false), + new SnapshotColumn(1, "Name", "nvarchar(50)", "string", IsNullable: true), + ], + snapshot.Columns); + Assert.Equal( + [new SnapshotParameter("id", "int", "int")], + snapshot.Parameters); + } + + [Fact] + public void Should_ReadEmptyCollections_When_QueryHasNoColumnsOrParameters() + { + var read = QuerySnapshotReader.TryRead( + """{ "commandText": "DELETE FROM dbo.Items", "provider": "sqlserver", "columns": [], "parameters": [] }""", + out var snapshot); + + Assert.True(read); + Assert.NotNull(snapshot); + Assert.Empty(snapshot.Columns); + Assert.Empty(snapshot.Parameters); + } + + [Fact] + public void Should_UnescapeStrings_When_SnapshotUsesJsonEscapes() + { + var read = QuerySnapshotReader.TryRead( + """{ "commandText": "SELECT '\"' AS Q,\n1 AS N", "provider": "sqlserver", "columns": [], "parameters": [] }""", + out var snapshot); + + Assert.True(read); + Assert.Equal("SELECT '\"' AS Q,\n1 AS N", snapshot!.CommandText); + } + + [Fact] + public void Should_IgnoreUnknownFields_When_SnapshotCarriesExtraMetadata() + { + var read = QuerySnapshotReader.TryRead( + """ + { + "commandText": "SELECT 1", "provider": "sqlserver", + "describedAtUtc": "2026-07-11T00:00:00Z", "schemaVersion": 2, + "columns": [ { "ordinal": 0, "name": "", "sqlTypeName": "int", "clrTypeText": "int", "isNullable": false, "extra": null } ], + "parameters": [] + } + """, + out var snapshot); + + Assert.True(read); + Assert.Equal("", snapshot!.Columns[0].Name); + } + + [Theory] + [InlineData("")] + [InlineData("not json at all")] + [InlineData("{")] + [InlineData("""{ "commandText": "SELECT 1" }""")] + [InlineData("""{ "commandText": "SELECT 1", "provider": "sqlserver", "columns": [], "parameters": [] } trailing""")] + [InlineData("""{ "commandText": 42, "provider": "sqlserver", "columns": [], "parameters": [] }""")] + [InlineData("""{ "commandText": "SELECT 1", "provider": "sqlserver", "columns": {}, "parameters": [] }""")] + [InlineData("""{ "commandText": "SELECT 1", "provider": "sqlserver", "columns": [ { "ordinal": 0 } ], "parameters": [] }""")] + [InlineData("""{ "commandText": "SELECT 1", "provider": "sqlserver", "columns": [ { "ordinal": 0.5, "name": "", "sqlTypeName": "int", "clrTypeText": "int", "isNullable": false } ], "parameters": [] }""")] + [InlineData("""{ "commandText": "SELECT 1", "provider": "sqlserver", "columns": [], "parameters": [ { "name": "id" } ] }""")] + public void Should_ReturnFalse_When_SnapshotIsMalformedOrIncomplete(string text) + { + var read = QuerySnapshotReader.TryRead(text, out var snapshot); + + Assert.False(read); + Assert.Null(snapshot); + } +} From 7db110aa3ebd66f7947041bd1b5e21150a178fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 21:32:02 +0100 Subject: [PATCH 4/7] Verify declared columns against snapshot result sets QueryVerifier compares a parsed query method with its snapshot and reports SQLB103-107: no result set at all, missing named columns, CLR type mismatches, database-nullable columns declared non-nullable, and unread result columns. Matching is by case-insensitive name because the generated code binds with GetOrdinal, and by mapped CLR type because the database's SQL type suggestions are inferences. The over-nullable direction (declared nullable, column non-nullable) is deliberately silent - it is safe at runtime. Co-Authored-By: Claude Fable 5 --- .../AnalyzerReleases.Unshipped.md | 5 + src/SqlBound.Generators/QueryVerifier.cs | 130 ++++++++++++++ .../SqlVerificationDiagnostics.cs | 54 ++++++ .../QueryVerifierColumnTests.cs | 158 ++++++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 src/SqlBound.Generators/QueryVerifier.cs create mode 100644 src/SqlBound.Generators/SqlVerificationDiagnostics.cs create mode 100644 test/SqlBound.Generators.UnitTests/QueryVerifierColumnTests.cs diff --git a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md index 7c95ea9..17a4c9e 100644 --- a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md +++ b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md @@ -15,3 +15,8 @@ SQLB007 | SqlBound.Usage | Error | [SqlQuery] command text must not be empty 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] +SQLB103 | SqlBound.Verification | Error | Statement produces no result set but the method expects one +SQLB104 | SqlBound.Verification | Error | Result set has no column with the declared name +SQLB105 | SqlBound.Verification | Error | Column CLR type differs from the declaration +SQLB106 | SqlBound.Verification | Error | Database column is nullable but declared non-nullable +SQLB107 | SqlBound.Verification | Info | Result set returns columns the method never reads diff --git a/src/SqlBound.Generators/QueryVerifier.cs b/src/SqlBound.Generators/QueryVerifier.cs new file mode 100644 index 0000000..495192b --- /dev/null +++ b/src/SqlBound.Generators/QueryVerifier.cs @@ -0,0 +1,130 @@ +using Microsoft.CodeAnalysis; + +namespace SqlBound.Generators; + +/// +/// Compares a parsed query method against its committed snapshot and yields the verification +/// findings. Pure and location-free: the analyzer attaches locations when reporting. Column +/// matching is by name (case-insensitive), not ordinal, because the generated code binds columns +/// with GetOrdinal(name); types compare by mapped CLR type text, not SQL type names, +/// because the database's suggested SQL types are inferences (e.g. a widened decimal). +/// +internal static class QueryVerifier +{ + public static IReadOnlyList Verify(QueryMethodModel model, QuerySnapshot snapshot) + { + var findings = new List(); + VerifyColumns(model, snapshot, findings); + return findings; + } + + private static void VerifyColumns(QueryMethodModel model, QuerySnapshot snapshot, List findings) + { + if (model.Shape is ResultShape.Execute or ResultShape.ExecuteDiscard) + { + return; + } + + if (snapshot.Columns.Count == 0) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.StatementProducesNoResultSet, model.MethodName)); + return; + } + + if (model.ElementKind == ResultElementKind.Scalar) + { + var first = snapshot.Columns[0]; + var declared = model.Columns[0]; + CompareColumn(model, declared, first, DisplayName(first), findings); + if (snapshot.Columns.Count > 1) + { + ReportUnreadColumns(model, snapshot.Columns.Skip(1), findings); + } + + return; + } + + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var column in snapshot.Columns) + { + // On duplicate names keep the first: that is the one GetOrdinal resolves. + if (!byName.ContainsKey(column.Name)) + { + byName.Add(column.Name, column); + } + } + + var readNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var declared in model.Columns) + { + if (!byName.TryGetValue(declared.Name, out var described)) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.ResultSetColumnMissing, declared.Name, model.MethodName)); + continue; + } + + readNames.Add(described.Name); + CompareColumn(model, declared, described, described.Name, findings); + } + + var unread = snapshot.Columns.Where(column => !readNames.Contains(column.Name)).ToArray(); + if (unread.Length > 0) + { + ReportUnreadColumns(model, unread, findings); + } + } + + private static void CompareColumn( + QueryMethodModel model, + ColumnModel declared, + SnapshotColumn described, + string columnDisplayName, + List findings) + { + if (StripNullableSuffix(declared.TypeText) != described.ClrTypeText) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.ColumnTypeMismatch, + columnDisplayName, + described.ClrTypeText, + described.SqlTypeName, + model.MethodName, + declared.TypeText)); + return; + } + + if (described.IsNullable && !declared.IsNullable) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.ColumnNullabilityMismatch, columnDisplayName, model.MethodName)); + } + } + + private static void ReportUnreadColumns( + QueryMethodModel model, IEnumerable unread, List findings) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.ResultSetHasUnreadColumns, + model.MethodName, + string.Join(", ", unread.Select(DisplayName)))); + } + + private static string DisplayName(SnapshotColumn column) => + column.Name.Length > 0 ? column.Name : $"#{column.Ordinal}"; + + private static string StripNullableSuffix(string typeText) => + typeText.Length > 0 && typeText[typeText.Length - 1] == '?' + ? typeText.Substring(0, typeText.Length - 1) + : typeText; +} + +/// One verification finding: a descriptor plus its message arguments, no location. +internal sealed record VerificationFinding(DiagnosticDescriptor Descriptor, EquatableArray MessageArgs) +{ + public VerificationFinding(DiagnosticDescriptor descriptor, params string[] args) + : this(descriptor, new EquatableArray(args)) + { + } +} diff --git a/src/SqlBound.Generators/SqlVerificationDiagnostics.cs b/src/SqlBound.Generators/SqlVerificationDiagnostics.cs new file mode 100644 index 0000000..7eaed44 --- /dev/null +++ b/src/SqlBound.Generators/SqlVerificationDiagnostics.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis; + +namespace SqlBound.Generators; + +/// +/// Descriptors for the snapshot-based verification diagnostics (SQLB1xx): mismatches between a +/// query method's declared signature and the database-described metadata committed under +/// .sqlbound/. Distinct from (SQLB0xx), which validates +/// how the attributes are used and never needs a snapshot. +/// +internal static class SqlVerificationDiagnostics +{ + private const string Category = "SqlBound.Verification"; + + public static readonly DiagnosticDescriptor StatementProducesNoResultSet = new( + "SQLB103", + "Statement produces no result set", + "'{0}' expects a result set, but the database reports the statement produces none", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ResultSetColumnMissing = new( + "SQLB104", + "Result set column missing", + "The result set has no column named '{0}', which '{1}' reads", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ColumnTypeMismatch = new( + "SQLB105", + "Column type mismatch", + "Column '{0}' is '{1}' ({2}) in the database, but '{3}' declares '{4}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ColumnNullabilityMismatch = new( + "SQLB106", + "Column nullability mismatch", + "Column '{0}' is nullable in the database, but '{1}' declares it non-nullable", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ResultSetHasUnreadColumns = new( + "SQLB107", + "Result set has unread columns", + "The result set returns columns '{0}' never reads: {1}", + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true); +} diff --git a/test/SqlBound.Generators.UnitTests/QueryVerifierColumnTests.cs b/test/SqlBound.Generators.UnitTests/QueryVerifierColumnTests.cs new file mode 100644 index 0000000..4438aa5 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/QueryVerifierColumnTests.cs @@ -0,0 +1,158 @@ +namespace SqlBound.Generators.UnitTests; + +public sealed class QueryVerifierColumnTests +{ + [Fact] + public void Should_ReportNothing_When_ColumnsMatchByNameTypeAndNullability() + { + var model = RowModel( + new ColumnModel("Id", "int", "GetInt32", IsNullable: false), + new ColumnModel("Price", "decimal?", "GetDecimal", IsNullable: true)); + var snapshot = Snapshot( + new SnapshotColumn(0, "Id", "int", "int", IsNullable: false), + new SnapshotColumn(1, "Price", "decimal(18,2)", "decimal", IsNullable: true)); + + Assert.Empty(QueryVerifier.Verify(model, snapshot)); + } + + [Fact] + public void Should_ReportNothing_When_ColumnNamesDifferOnlyByCase() + { + var model = RowModel(new ColumnModel("id", "int", "GetInt32", IsNullable: false)); + var snapshot = Snapshot(new SnapshotColumn(0, "ID", "int", "int", IsNullable: false)); + + Assert.Empty(QueryVerifier.Verify(model, snapshot)); + } + + [Fact] + public void Should_ReportNothing_When_DeclarationIsNullableOverNonNullableColumn() + { + // Over-nullable declarations are safe: the generated code just never sees a DBNull. + var model = RowModel(new ColumnModel("Name", "string?", "GetString", IsNullable: true)); + var snapshot = Snapshot(new SnapshotColumn(0, "Name", "nvarchar(50)", "string", IsNullable: false)); + + Assert.Empty(QueryVerifier.Verify(model, snapshot)); + } + + [Fact] + public void Should_ReportNoResultSet_When_QueryShapeDescribesZeroColumns() + { + var model = RowModel(new ColumnModel("Id", "int", "GetInt32", IsNullable: false)); + var snapshot = Snapshot(); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB103", finding.Descriptor.Id); + } + + [Fact] + public void Should_ReportMissingColumn_When_DeclaredNameIsAbsentFromResultSet() + { + var model = RowModel( + new ColumnModel("Id", "int", "GetInt32", IsNullable: false), + new ColumnModel("Label", "string", "GetString", IsNullable: false)); + var snapshot = Snapshot( + new SnapshotColumn(0, "Id", "int", "int", IsNullable: false), + new SnapshotColumn(1, "Name", "nvarchar(50)", "string", IsNullable: false)); + + var findings = QueryVerifier.Verify(model, snapshot); + + Assert.Contains(findings, f => f.Descriptor.Id == "SQLB104" && f.MessageArgs.Contains("Label")); + } + + [Fact] + public void Should_ReportTypeMismatch_When_DeclaredColumnTypeDiffers() + { + var model = RowModel(new ColumnModel("Id", "long", "GetInt64", IsNullable: false)); + var snapshot = Snapshot(new SnapshotColumn(0, "Id", "int", "int", IsNullable: false)); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB105", finding.Descriptor.Id); + Assert.Contains("long", finding.MessageArgs); + Assert.Contains("int", finding.MessageArgs); + } + + [Fact] + public void Should_ReportNullabilityMismatch_When_DatabaseColumnIsNullableButDeclarationIsNot() + { + var model = RowModel(new ColumnModel("Price", "decimal", "GetDecimal", IsNullable: false)); + var snapshot = Snapshot(new SnapshotColumn(0, "Price", "decimal(18,2)", "decimal", IsNullable: true)); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB106", finding.Descriptor.Id); + } + + [Fact] + public void Should_ReportUnreadColumns_When_ResultSetReturnsMoreThanTheMethodReads() + { + var model = RowModel(new ColumnModel("Id", "int", "GetInt32", IsNullable: false)); + var snapshot = Snapshot( + new SnapshotColumn(0, "Id", "int", "int", IsNullable: false), + new SnapshotColumn(1, "Name", "nvarchar(50)", "string", IsNullable: false), + new SnapshotColumn(2, "Price", "decimal(18,2)", "decimal", IsNullable: true)); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB107", finding.Descriptor.Id); + Assert.Contains("Name, Price", finding.MessageArgs); + } + + [Fact] + public void Should_VerifyFirstColumnOnly_When_ShapeIsScalar() + { + var model = ScalarModel(new ColumnModel(string.Empty, "int", "GetInt32", IsNullable: false)); + var snapshot = Snapshot(new SnapshotColumn(0, "", "bigint", "long", IsNullable: false)); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB105", finding.Descriptor.Id); + } + + [Fact] + public void Should_ReportUnreadColumns_When_ScalarQueryReturnsSeveralColumns() + { + var model = ScalarModel(new ColumnModel(string.Empty, "int", "GetInt32", IsNullable: false)); + var snapshot = Snapshot( + new SnapshotColumn(0, "Total", "int", "int", IsNullable: false), + new SnapshotColumn(1, "Name", "nvarchar(50)", "string", IsNullable: false)); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB107", finding.Descriptor.Id); + } + + [Fact] + public void Should_ReportNullabilityMismatch_When_ScalarColumnIsNullableButDeclarationIsNot() + { + var model = ScalarModel(new ColumnModel(string.Empty, "decimal", "GetDecimal", IsNullable: false)); + var snapshot = Snapshot(new SnapshotColumn(0, "", "decimal(18,2)", "decimal", IsNullable: true)); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB106", finding.Descriptor.Id); + } + + private static QueryMethodModel RowModel(params ColumnModel[] columns) => + Model(ResultShape.RowList, ResultElementKind.Row, columns); + + private static QueryMethodModel ScalarModel(params ColumnModel[] columns) => + Model(ResultShape.SingleRow, ResultElementKind.Scalar, columns); + + private static QueryMethodModel Model(ResultShape shape, ResultElementKind elementKind, ColumnModel[] columns) => + new( + "App", + new EquatableArray([new ContainingTypeModel("class", "ItemQueries")]), + "public", + "GetItemsAsync", + IsExtensionMethod: false, + "SELECT Id FROM dbo.Items", + "global::System.Threading.Tasks.Task", + shape, + elementKind, + RowMappingKind.Constructor, + "global::App.Item", + new EquatableArray(columns), + EquatableArray.Empty); + + private static QuerySnapshot Snapshot(params SnapshotColumn[] columns) => + new( + "SELECT Id FROM dbo.Items", + "sqlserver", + new EquatableArray(columns), + EquatableArray.Empty); +} From 5b7fca951c1710de9e0cd11eea9535c2ac84e1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 21:34:07 +0100 Subject: [PATCH 5/7] Verify parameters and execute statements against snapshots SQLB108-111 complete the comparison rules: a statement parameter with no matching method parameter is an error (guaranteed runtime failure), a method scalar the SQL never uses is a warning, parameter CLR type mismatches are errors, and an [SqlExecute] statement that returns a result set warns that the rows are silently discarded. Infrastructure parameters (connection, transaction, cancellation token) are excluded from matching. Co-Authored-By: Claude Fable 5 --- .../AnalyzerReleases.Unshipped.md | 4 + src/SqlBound.Generators/QueryVerifier.cs | 51 +++++++ .../SqlVerificationDiagnostics.cs | 32 +++++ .../QueryVerifierParameterTests.cs | 134 ++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 test/SqlBound.Generators.UnitTests/QueryVerifierParameterTests.cs diff --git a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md index 17a4c9e..a10a733 100644 --- a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md +++ b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md @@ -20,3 +20,7 @@ SQLB104 | SqlBound.Verification | Error | Result set has no column with the decl SQLB105 | SqlBound.Verification | Error | Column CLR type differs from the declaration SQLB106 | SqlBound.Verification | Error | Database column is nullable but declared non-nullable SQLB107 | SqlBound.Verification | Info | Result set returns columns the method never reads +SQLB108 | SqlBound.Verification | Error | Statement uses a parameter the method does not declare +SQLB109 | SqlBound.Verification | Warning | Method declares a scalar parameter the statement never uses +SQLB110 | SqlBound.Verification | Error | Parameter CLR type differs from the declaration +SQLB111 | SqlBound.Verification | Warning | [SqlExecute] statement returns a result set it discards diff --git a/src/SqlBound.Generators/QueryVerifier.cs b/src/SqlBound.Generators/QueryVerifier.cs index 495192b..3a5b8ce 100644 --- a/src/SqlBound.Generators/QueryVerifier.cs +++ b/src/SqlBound.Generators/QueryVerifier.cs @@ -15,6 +15,7 @@ public static IReadOnlyList Verify(QueryMethodModel model, { var findings = new List(); VerifyColumns(model, snapshot, findings); + VerifyParameters(model, snapshot, findings); return findings; } @@ -22,6 +23,12 @@ private static void VerifyColumns(QueryMethodModel model, QuerySnapshot snapshot { if (model.Shape is ResultShape.Execute or ResultShape.ExecuteDiscard) { + if (snapshot.Columns.Count > 0) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.ExecuteStatementReturnsResultSet, model.MethodName)); + } + return; } @@ -76,6 +83,50 @@ private static void VerifyColumns(QueryMethodModel model, QuerySnapshot snapshot } } + private static void VerifyParameters(QueryMethodModel model, QuerySnapshot snapshot, List findings) + { + var declaredByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in model.Parameters) + { + if (parameter.Kind == ParameterKind.Scalar) + { + declaredByName[parameter.Name] = parameter; + } + } + + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var described in snapshot.Parameters) + { + if (!declaredByName.TryGetValue(described.Name, out var declared)) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.SqlParameterMissingFromMethod, described.Name, model.MethodName)); + continue; + } + + usedNames.Add(declared.Name); + if (StripNullableSuffix(declared.TypeText) != described.ClrTypeText) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.ParameterTypeMismatch, + declared.Name, + described.ClrTypeText, + described.SqlTypeName, + model.MethodName, + declared.TypeText)); + } + } + + foreach (var parameter in model.Parameters) + { + if (parameter.Kind == ParameterKind.Scalar && !usedNames.Contains(parameter.Name)) + { + findings.Add(new VerificationFinding( + SqlVerificationDiagnostics.MethodParameterUnusedBySql, parameter.Name, model.MethodName)); + } + } + } + private static void CompareColumn( QueryMethodModel model, ColumnModel declared, diff --git a/src/SqlBound.Generators/SqlVerificationDiagnostics.cs b/src/SqlBound.Generators/SqlVerificationDiagnostics.cs index 7eaed44..b979acc 100644 --- a/src/SqlBound.Generators/SqlVerificationDiagnostics.cs +++ b/src/SqlBound.Generators/SqlVerificationDiagnostics.cs @@ -51,4 +51,36 @@ internal static class SqlVerificationDiagnostics Category, DiagnosticSeverity.Info, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor SqlParameterMissingFromMethod = new( + "SQLB108", + "SQL parameter missing from the method", + "The statement uses parameter '@{0}', but '{1}' declares no matching parameter", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MethodParameterUnusedBySql = new( + "SQLB109", + "Method parameter unused by the SQL", + "Parameter '{0}' of '{1}' is never used by the statement", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ParameterTypeMismatch = new( + "SQLB110", + "Parameter type mismatch", + "Parameter '{0}' is '{1}' ({2}) in the database, but '{3}' declares '{4}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ExecuteStatementReturnsResultSet = new( + "SQLB111", + "Execute statement returns a result set", + "The statement returns a result set that '{0}' discards; use [SqlQuery] to read it", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); } diff --git a/test/SqlBound.Generators.UnitTests/QueryVerifierParameterTests.cs b/test/SqlBound.Generators.UnitTests/QueryVerifierParameterTests.cs new file mode 100644 index 0000000..03b843a --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/QueryVerifierParameterTests.cs @@ -0,0 +1,134 @@ +namespace SqlBound.Generators.UnitTests; + +public sealed class QueryVerifierParameterTests +{ + [Fact] + public void Should_ReportNothing_When_ParametersMatchByNameAndType() + { + var model = QueryModel( + new MethodParameterModel("id", "int", ParameterKind.Scalar), + new MethodParameterModel("name", "string?", ParameterKind.Scalar, CanBeNull: true)); + var snapshot = Snapshot( + new SnapshotParameter("id", "int", "int"), + new SnapshotParameter("name", "nvarchar(50)", "string")); + + Assert.Empty(QueryVerifier.Verify(model, snapshot)); + } + + [Fact] + public void Should_ReportNothing_When_ParameterNamesDifferOnlyByCase() + { + var model = QueryModel(new MethodParameterModel("Id", "int", ParameterKind.Scalar)); + var snapshot = Snapshot(new SnapshotParameter("id", "int", "int")); + + Assert.Empty(QueryVerifier.Verify(model, snapshot)); + } + + [Fact] + public void Should_IgnoreInfrastructureParameters_When_MatchingAgainstSqlParameters() + { + var model = QueryModel( + new MethodParameterModel("connection", "global::System.Data.Common.DbConnection", ParameterKind.Connection), + new MethodParameterModel("transaction", "global::System.Data.Common.DbTransaction", ParameterKind.Transaction), + new MethodParameterModel("id", "int", ParameterKind.Scalar), + new MethodParameterModel("cancellationToken", "global::System.Threading.CancellationToken", ParameterKind.CancellationToken)); + var snapshot = Snapshot(new SnapshotParameter("id", "int", "int")); + + Assert.Empty(QueryVerifier.Verify(model, snapshot)); + } + + [Fact] + public void Should_ReportMissingParameter_When_SqlUsesPlaceholderTheMethodLacks() + { + var model = QueryModel(new MethodParameterModel("id", "int", ParameterKind.Scalar)); + var snapshot = Snapshot( + new SnapshotParameter("id", "int", "int"), + new SnapshotParameter("category", "nvarchar(20)", "string")); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB108", finding.Descriptor.Id); + Assert.Contains("category", finding.MessageArgs); + } + + [Fact] + public void Should_ReportUnusedParameter_When_MethodDeclaresScalarTheSqlNeverUses() + { + var model = QueryModel( + new MethodParameterModel("id", "int", ParameterKind.Scalar), + new MethodParameterModel("unused", "int", ParameterKind.Scalar)); + var snapshot = Snapshot(new SnapshotParameter("id", "int", "int")); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB109", finding.Descriptor.Id); + Assert.Contains("unused", finding.MessageArgs); + } + + [Fact] + public void Should_ReportParameterTypeMismatch_When_DeclaredTypeDiffers() + { + var model = QueryModel(new MethodParameterModel("id", "string", ParameterKind.Scalar)); + var snapshot = Snapshot(new SnapshotParameter("id", "int", "int")); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB110", finding.Descriptor.Id); + Assert.Contains("string", finding.MessageArgs); + Assert.Contains("int", finding.MessageArgs); + } + + [Fact] + public void Should_ReportResultSetOnExecute_When_ExecuteStatementReturnsColumns() + { + var model = ExecuteModel(); + var snapshot = new QuerySnapshot( + "DELETE FROM dbo.Items OUTPUT DELETED.Id", + "sqlserver", + new EquatableArray([new SnapshotColumn(0, "Id", "int", "int", IsNullable: false)]), + EquatableArray.Empty); + + var finding = Assert.Single(QueryVerifier.Verify(model, snapshot)); + Assert.Equal("SQLB111", finding.Descriptor.Id); + } + + [Fact] + public void Should_ReportNothing_When_ExecuteStatementReturnsNoColumns() + { + var snapshot = new QuerySnapshot( + "DELETE FROM dbo.Items", + "sqlserver", + EquatableArray.Empty, + EquatableArray.Empty); + + Assert.Empty(QueryVerifier.Verify(ExecuteModel(), snapshot)); + } + + private static QueryMethodModel QueryModel(params MethodParameterModel[] parameters) => + Model(ResultShape.SingleRow, ResultElementKind.Scalar, + [new ColumnModel(string.Empty, "int", "GetInt32", IsNullable: false)], parameters); + + private static QueryMethodModel ExecuteModel(params MethodParameterModel[] parameters) => + Model(ResultShape.Execute, ResultElementKind.Row, [], parameters); + + private static QueryMethodModel Model( + ResultShape shape, ResultElementKind elementKind, ColumnModel[] columns, MethodParameterModel[] parameters) => + new( + "App", + new EquatableArray([new ContainingTypeModel("class", "ItemQueries")]), + "public", + "GetItemAsync", + IsExtensionMethod: false, + "SELECT COUNT(*) FROM dbo.Items", + "global::System.Threading.Tasks.Task", + shape, + elementKind, + RowMappingKind.Constructor, + "int", + new EquatableArray(columns), + new EquatableArray(parameters)); + + private static QuerySnapshot Snapshot(params SnapshotParameter[] parameters) => + new( + "SELECT COUNT(*) FROM dbo.Items", + "sqlserver", + new EquatableArray([new SnapshotColumn(0, "", "int", "int", IsNullable: false)]), + new EquatableArray(parameters)); +} From d941b30130a943dae86c85eea69940c5102f51b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 21:39:18 +0100 Subject: [PATCH 6/7] Verify queries against committed snapshots in the analyzer SqlQueryVerificationAnalyzer closes ADR 0001's loop: it parses each attributed method with the generator's own parser, pairs it with its .sqlbound/ snapshot by SHA-256 of the raw command text, and reports QueryVerifier findings at the method identifier. Verification is opt-in by presence: zero .sqlbound/ AdditionalFiles means total silence for codegen-only consumers, while any wired snapshot makes a query without one SQLB101 (stale coverage) and an unreadable or text-mismatched file SQLB102. Usage errors stay the generator's to report - the analyzer skips methods that produce no model. Co-Authored-By: Claude Fable 5 --- .../AnalyzerReleases.Unshipped.md | 2 + src/SqlBound.Generators/SnapshotKey.cs | 26 +++ .../SqlQueryVerificationAnalyzer.cs | 191 ++++++++++++++++++ .../SqlVerificationDiagnostics.cs | 16 ++ .../AnalyzerHarness.cs | 53 +++++ .../SqlQueryVerificationAnalyzerTests.cs | 183 +++++++++++++++++ 6 files changed, 471 insertions(+) create mode 100644 src/SqlBound.Generators/SnapshotKey.cs create mode 100644 src/SqlBound.Generators/SqlQueryVerificationAnalyzer.cs create mode 100644 test/SqlBound.Generators.UnitTests/AnalyzerHarness.cs create mode 100644 test/SqlBound.Generators.UnitTests/SqlQueryVerificationAnalyzerTests.cs diff --git a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md index a10a733..51b3e7e 100644 --- a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md +++ b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md @@ -15,6 +15,8 @@ SQLB007 | SqlBound.Usage | Error | [SqlQuery] command text must not be empty 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] +SQLB101 | SqlBound.Verification | Warning | Query has no .sqlbound snapshot (reported only once verification is opted in) +SQLB102 | SqlBound.Verification | Warning | Snapshot file is unreadable or stale SQLB103 | SqlBound.Verification | Error | Statement produces no result set but the method expects one SQLB104 | SqlBound.Verification | Error | Result set has no column with the declared name SQLB105 | SqlBound.Verification | Error | Column CLR type differs from the declaration diff --git a/src/SqlBound.Generators/SnapshotKey.cs b/src/SqlBound.Generators/SnapshotKey.cs new file mode 100644 index 0000000..d9aa2a4 --- /dev/null +++ b/src/SqlBound.Generators/SnapshotKey.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SqlBound.Generators; + +/// +/// Derives the key that pairs a query with its .sqlbound/ snapshot file: the lowercase hex +/// SHA-256 of the raw UTF-8 command text. The command text is hashed as written — no +/// normalization — so any edit to the SQL requires re-running prepare, which is exactly +/// the staleness signal the analyzer relies on. +/// +internal static class SnapshotKey +{ + public static string Compute(string commandText) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(commandText)); + var builder = new StringBuilder(hash.Length * 2); + foreach (var value in hash) + { + builder.Append(value.ToString("x2")); + } + + return builder.ToString(); + } +} diff --git a/src/SqlBound.Generators/SqlQueryVerificationAnalyzer.cs b/src/SqlBound.Generators/SqlQueryVerificationAnalyzer.cs new file mode 100644 index 0000000..d6a14ef --- /dev/null +++ b/src/SqlBound.Generators/SqlQueryVerificationAnalyzer.cs @@ -0,0 +1,191 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SqlBound.Generators; + +/// +/// Verifies [SqlQuery]/[SqlExecute] methods against the committed .sqlbound/ +/// snapshots wired in as AdditionalFiles, per ADR 0001: this analyzer never opens a +/// database connection — the snapshots are its only source of database truth. Verification is +/// opt-in by presence (ADR 0003): with no .sqlbound/ files at all the analyzer stays +/// silent, so codegen-only consumers are never nagged; once any snapshot is wired, a query +/// without one is reported as stale coverage (SQLB101). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class SqlQueryVerificationAnalyzer : DiagnosticAnalyzer +{ + private const string QueryAttributeName = "SqlBound.SqlQueryAttribute"; + private const string ExecuteAttributeName = "SqlBound.SqlExecuteAttribute"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + SqlVerificationDiagnostics.QueryHasNoSnapshot, + SqlVerificationDiagnostics.SnapshotInvalidOrStale, + SqlVerificationDiagnostics.StatementProducesNoResultSet, + SqlVerificationDiagnostics.ResultSetColumnMissing, + SqlVerificationDiagnostics.ColumnTypeMismatch, + SqlVerificationDiagnostics.ColumnNullabilityMismatch, + SqlVerificationDiagnostics.ResultSetHasUnreadColumns, + SqlVerificationDiagnostics.SqlParameterMissingFromMethod, + SqlVerificationDiagnostics.MethodParameterUnusedBySql, + SqlVerificationDiagnostics.ParameterTypeMismatch, + SqlVerificationDiagnostics.ExecuteStatementReturnsResultSet); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + var snapshots = LoadSnapshots(context.Options.AdditionalFiles, context.CancellationToken); + if (snapshots is null) + { + return; + } + + context.RegisterSyntaxNodeAction( + nodeContext => VerifyMethod(nodeContext, snapshots), SyntaxKind.MethodDeclaration); + } + + /// + /// Loads every snapshot under a .sqlbound/ directory, keyed by the hash embedded in + /// the file name. Returns null when no .sqlbound/ files are wired at all — + /// the not-opted-in signal. + /// + private static Dictionary? LoadSnapshots( + ImmutableArray additionalFiles, CancellationToken cancellationToken) + { + Dictionary? snapshots = null; + foreach (var file in additionalFiles) + { + var normalizedPath = file.Path.Replace('\\', '/'); + if (!normalizedPath.Contains("/.sqlbound/") && !normalizedPath.StartsWith(".sqlbound/", StringComparison.Ordinal)) + { + continue; + } + + // Any .sqlbound/ file opts the project in, even one this analyzer version cannot key. + snapshots ??= new Dictionary(StringComparer.Ordinal); + var fileName = normalizedPath.Substring(normalizedPath.LastIndexOf('/') + 1); + if (!TryReadKeyFromFileName(fileName, out var key)) + { + continue; + } + + var text = file.GetText(cancellationToken)?.ToString(); + QuerySnapshot? snapshot = null; + if (text is not null && QuerySnapshotReader.TryRead(text, out var parsed)) + { + snapshot = parsed; + } + + snapshots[key] = new SnapshotEntry(fileName, snapshot); + } + + return snapshots; + } + + private static bool TryReadKeyFromFileName(string fileName, out string key) + { + const string prefix = "query-"; + const string suffix = ".json"; + key = string.Empty; + if (!fileName.StartsWith(prefix, StringComparison.Ordinal) + || !fileName.EndsWith(suffix, StringComparison.Ordinal)) + { + return false; + } + + var hex = fileName.Substring(prefix.Length, fileName.Length - prefix.Length - suffix.Length); + if (hex.Length != 64) + { + return false; + } + + foreach (var character in hex) + { + if (character is not ((>= '0' and <= '9') or (>= 'a' and <= 'f'))) + { + return false; + } + } + + key = hex; + return true; + } + + private static void VerifyMethod(SyntaxNodeAnalysisContext context, Dictionary snapshots) + { + var node = (MethodDeclarationSyntax)context.Node; + if (node.AttributeLists.Count == 0 + || context.SemanticModel.GetDeclaredSymbol(node, context.CancellationToken) is not { } symbol) + { + return; + } + + AttributeData? attribute = null; + var isExecute = false; + foreach (var candidate in symbol.GetAttributes()) + { + var attributeName = candidate.AttributeClass?.ToDisplayString(); + if (attributeName is not (QueryAttributeName or ExecuteAttributeName)) + { + continue; + } + + // Partial methods surface one symbol from two declarations; act only on the node + // that syntactically carries the attribute so each method verifies exactly once. + if (candidate.ApplicationSyntaxReference is { } reference + && reference.SyntaxTree == node.SyntaxTree + && node.FullSpan.Contains(reference.Span)) + { + attribute = candidate; + isExecute = attributeName == ExecuteAttributeName; + break; + } + } + + if (attribute is null) + { + return; + } + + var result = QueryMethodParser.Parse(symbol, node, attribute, context.SemanticModel.Compilation, isExecute); + if (result.Method is not { } model) + { + // Usage errors (SQLB0xx) are the generator's to report; nothing to verify. + return; + } + + var location = node.Identifier.GetLocation(); + if (!snapshots.TryGetValue(SnapshotKey.Compute(model.CommandText), out var entry)) + { + context.ReportDiagnostic(Diagnostic.Create( + SqlVerificationDiagnostics.QueryHasNoSnapshot, location, model.MethodName)); + return; + } + + if (entry.Snapshot is null || entry.Snapshot.CommandText != model.CommandText) + { + context.ReportDiagnostic(Diagnostic.Create( + SqlVerificationDiagnostics.SnapshotInvalidOrStale, location, entry.FileName)); + return; + } + + foreach (var finding in QueryVerifier.Verify(model, entry.Snapshot)) + { + object[] messageArgs = [.. finding.MessageArgs]; + context.ReportDiagnostic(Diagnostic.Create(finding.Descriptor, location, messageArgs)); + } + } + + private sealed record SnapshotEntry(string FileName, QuerySnapshot? Snapshot); +} diff --git a/src/SqlBound.Generators/SqlVerificationDiagnostics.cs b/src/SqlBound.Generators/SqlVerificationDiagnostics.cs index b979acc..37b6303 100644 --- a/src/SqlBound.Generators/SqlVerificationDiagnostics.cs +++ b/src/SqlBound.Generators/SqlVerificationDiagnostics.cs @@ -12,6 +12,22 @@ internal static class SqlVerificationDiagnostics { private const string Category = "SqlBound.Verification"; + public static readonly DiagnosticDescriptor QueryHasNoSnapshot = new( + "SQLB101", + "Query has no snapshot", + "No .sqlbound snapshot exists for the query in '{0}'; run the prepare step to describe it against the database", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor SnapshotInvalidOrStale = new( + "SQLB102", + "Snapshot is invalid or stale", + "Snapshot '{0}' is unreadable or no longer matches the query's command text; re-run the prepare step", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + public static readonly DiagnosticDescriptor StatementProducesNoResultSet = new( "SQLB103", "Statement produces no result set", diff --git a/test/SqlBound.Generators.UnitTests/AnalyzerHarness.cs b/test/SqlBound.Generators.UnitTests/AnalyzerHarness.cs new file mode 100644 index 0000000..2f6050f --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/AnalyzerHarness.cs @@ -0,0 +1,53 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace SqlBound.Generators.UnitTests; + +/// +/// Runs against an in-memory compilation with in-memory +/// additional files standing in for the committed .sqlbound/ snapshots. +/// +internal static class AnalyzerHarness +{ + private static readonly IReadOnlyList References = CreateReferences(); + + public static async Task> RunAsync( + string source, params (string Path, string Content)[] additionalFiles) + { + var compilation = CSharpCompilation.Create( + "SqlBound.Generators.UnitTests.AnalyzerTarget", + [CSharpSyntaxTree.ParseText(source)], + References, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + + var options = new AnalyzerOptions( + [.. additionalFiles.Select(file => (AdditionalText)new InMemoryAdditionalText(file.Path, file.Content))]); + var withAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new SqlQueryVerificationAnalyzer()), options); + return await withAnalyzers.GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken); + } + + 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; + } + + private sealed class InMemoryAdditionalText(string path, string content) : AdditionalText + { + public override string Path { get; } = path; + + public override SourceText GetText(CancellationToken cancellationToken = default) => + SourceText.From(content); + } +} diff --git a/test/SqlBound.Generators.UnitTests/SqlQueryVerificationAnalyzerTests.cs b/test/SqlBound.Generators.UnitTests/SqlQueryVerificationAnalyzerTests.cs new file mode 100644 index 0000000..5992fb2 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/SqlQueryVerificationAnalyzerTests.cs @@ -0,0 +1,183 @@ +namespace SqlBound.Generators.UnitTests; + +public sealed class SqlQueryVerificationAnalyzerTests +{ + private const string CommandText = "SELECT Id, Name FROM dbo.Items WHERE Id = @id"; + + private const string Source = + """ + using System.Collections.Generic; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + using SqlBound; + + namespace App; + + public static partial class ItemQueries + { + [SqlQuery("SELECT Id, Name FROM dbo.Items WHERE Id = @id")] + public static partial Task> GetItemsAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + } + + public sealed record Item(int Id, string Name); + """; + + private const string MatchingSnapshot = + """ + { + "commandText": "SELECT Id, Name FROM dbo.Items WHERE Id = @id", + "provider": "sqlserver", + "columns": [ + { "ordinal": 0, "name": "Id", "sqlTypeName": "int", "clrTypeText": "int", "isNullable": false }, + { "ordinal": 1, "name": "Name", "sqlTypeName": "nvarchar(50)", "clrTypeText": "string", "isNullable": false } + ], + "parameters": [ + { "name": "id", "sqlTypeName": "int", "clrTypeText": "int" } + ] + } + """; + + private static string SnapshotPath(string commandText) => + $"C:/repo/.sqlbound/query-{SnapshotKey.Compute(commandText)}.json"; + + [Fact] + public async Task Should_ReportNothing_When_NoSnapshotFilesAreWired() + { + var diagnostics = await AnalyzerHarness.RunAsync(Source); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task Should_ReportNothing_When_AdditionalFilesAreNotSqlBoundSnapshots() + { + var diagnostics = await AnalyzerHarness.RunAsync( + Source, ("C:/repo/docs/notes.json", """{ "unrelated": true }""")); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task Should_ReportNothing_When_SnapshotMatchesDeclaration() + { + var diagnostics = await AnalyzerHarness.RunAsync( + Source, (SnapshotPath(CommandText), MatchingSnapshot)); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task Should_ReportMissingSnapshot_When_ProjectHasSnapshotsButNotForThisQuery() + { + var diagnostics = await AnalyzerHarness.RunAsync( + Source, + (SnapshotPath("SELECT 1"), + """{ "commandText": "SELECT 1", "provider": "sqlserver", "columns": [], "parameters": [] }""")); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("SQLB101", diagnostic.Id); + Assert.Contains("GetItemsAsync", diagnostic.GetMessage()); + } + + [Fact] + public async Task Should_ReportInvalidSnapshot_When_FileIsMalformed() + { + var diagnostics = await AnalyzerHarness.RunAsync( + Source, (SnapshotPath(CommandText), "{ not json")); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("SQLB102", diagnostic.Id); + } + + [Fact] + public async Task Should_ReportInvalidSnapshot_When_EmbeddedCommandTextDiffersFromAttribute() + { + var staleSnapshot = MatchingSnapshot.Replace( + "SELECT Id, Name FROM dbo.Items WHERE Id = @id", + "SELECT Id FROM dbo.Items WHERE Id = @id"); + var diagnostics = await AnalyzerHarness.RunAsync( + Source, (SnapshotPath(CommandText), staleSnapshot)); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("SQLB102", diagnostic.Id); + } + + [Fact] + public async Task Should_ReportVerificationFindingsAtTheMethod_When_SnapshotDisagrees() + { + var mismatchingSnapshot = MatchingSnapshot.Replace( + "\"clrTypeText\": \"string\", \"isNullable\": false", + "\"clrTypeText\": \"string\", \"isNullable\": true"); + var diagnostics = await AnalyzerHarness.RunAsync( + Source, (SnapshotPath(CommandText), mismatchingSnapshot)); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("SQLB106", diagnostic.Id); + var squiggled = diagnostic.Location.SourceTree! + .GetText(TestContext.Current.CancellationToken) + .ToString(diagnostic.Location.SourceSpan); + Assert.Equal("GetItemsAsync", squiggled); + } + + [Fact] + public async Task Should_ReportNothing_When_MethodHasUsageErrors() + { + const string invalidSource = + """ + using System.Collections.Generic; + using System.Data.Common; + using System.Threading.Tasks; + using SqlBound; + + namespace App; + + public static class ItemQueries + { + [SqlQuery("SELECT Id FROM dbo.Items")] + public static Task> GetIdsAsync(DbConnection connection) => + Task.FromResult>([]); + } + """; + + var diagnostics = await AnalyzerHarness.RunAsync( + invalidSource, (SnapshotPath(CommandText), MatchingSnapshot)); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task Should_ReportExecuteResultSet_When_ExecuteSnapshotHasColumns() + { + const string executeSource = + """ + using System.Data.Common; + using System.Threading.Tasks; + using SqlBound; + + namespace App; + + public static partial class ItemCommands + { + [SqlExecute("DELETE FROM dbo.Items OUTPUT DELETED.Id")] + public static partial Task DeleteAllAsync(DbConnection connection); + } + """; + var snapshot = + """ + { + "commandText": "DELETE FROM dbo.Items OUTPUT DELETED.Id", + "provider": "sqlserver", + "columns": [ { "ordinal": 0, "name": "Id", "sqlTypeName": "int", "clrTypeText": "int", "isNullable": false } ], + "parameters": [] + } + """; + + var diagnostics = await AnalyzerHarness.RunAsync( + executeSource, (SnapshotPath("DELETE FROM dbo.Items OUTPUT DELETED.Id"), snapshot)); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("SQLB111", diagnostic.Id); + } +} From 165f0643f714fa2ff5006af1438117486d31f20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 21:40:56 +0100 Subject: [PATCH 7/7] Record the verification opt-in decision and diagnostic catalog ADR 0003 closes the question ADR 0001 deferred: the committed .sqlbound/ directory itself is the opt-in switch. No snapshots wired means the analyzer is silent, so codegen-only consumers are never nagged; any snapshot present makes a query without one a warning, surfacing the forgot-to-re-run-prepare hole in the IDE immediately. docs/diagnostics.md catalogs SQLB001-010 and SQLB101-111 with the two non-obvious comparison rules (name-based column matching, CLR-type comparison). Co-Authored-By: Claude Fable 5 --- ...erification-opt-in-by-snapshot-presence.md | 60 +++++++++++++++++++ docs/diagnostics.md | 52 ++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 docs/adr/0003-verification-opt-in-by-snapshot-presence.md create mode 100644 docs/diagnostics.md diff --git a/docs/adr/0003-verification-opt-in-by-snapshot-presence.md b/docs/adr/0003-verification-opt-in-by-snapshot-presence.md new file mode 100644 index 0000000..68eeb3f --- /dev/null +++ b/docs/adr/0003-verification-opt-in-by-snapshot-presence.md @@ -0,0 +1,60 @@ +# 3. Verification is opted into by snapshot presence, and a missing snapshot is then a warning + +## Status + +Accepted + +## Context + +ADR 0001 deliberately left one question open: what should the analyzer report when a +`[SqlQuery]`/`[SqlExecute]` method has *no* `.sqlbound/` snapshot at all, as opposed to a +mismatched one? Under the signature-driven codegen chosen in ADR 0002 the generator does not need +snapshots, so a missing one is not inherently an error — the code still compiles and runs. + +The tension is between two consumer populations: + +- **Codegen-only consumers** adopt SqlBound for reflection-free materialization and may have no + database reachable at build time. Nagging every query with an unfixable diagnostic (fatal under + `TreatWarningsAsErrors`) would make the package hostile out of the box. +- **Verification adopters** run `prepare` and commit snapshots. For them, a query without a + snapshot is precisely the failure mode ADR 0001 worries about: someone added or edited a query + and forgot to re-run `prepare`, silently losing compile-time verification for that query. + +A fixed severity cannot serve both: always-error breaks the first group, always-silent leaves the +second group's coverage hole open until CI runs `prepare --check` (M9). + +## Decision + +The committed `.sqlbound/` directory itself is the opt-in switch: + +- **No `.sqlbound/` files wired as `AdditionalFiles` → the verification analyzer is fully + silent.** A project that never runs `prepare` never hears about verification. +- **Any `.sqlbound/` file present → verification is on for the whole project.** Every query + method without a matching snapshot reports `SQLB101` (warning), an unreadable or + command-text-mismatched snapshot reports `SQLB102` (warning), and matched snapshots are + compared normally (SQLB103–111). + +"The team has opted into verification" is thereby a property of the repository — recorded by the +first committed snapshot — rather than of any per-project MSBuild flag that could drift between +projects and their snapshots. + +Severities remain tunable per consumer via `.editorconfig` +(`dotnet_diagnostic.SQLB101.severity = error` for teams that want missing coverage to fail the +build). + +## Consequences + +**Positive** + +- Zero-configuration adoption for codegen-only use; zero-configuration enforcement the moment the + first snapshot is committed. +- The forgot-to-re-run-`prepare` hole is surfaced in the IDE immediately, not first in CI. + +**Negative / trade-offs** + +- Deleting the whole `.sqlbound/` directory silently turns verification off; only M9's + `prepare --check` in CI can catch that. This is accepted — the same act is also the documented + way to genuinely opt out. +- The first `prepare` run flips every unprepared query in the project to SQLB101 at once. This is + the intended "now finish preparing" signal, but it can be noisy on large codebases adopting + verification incrementally; teams can downgrade SQLB101 via `.editorconfig` during migration. diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..6922b28 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,52 @@ +# Diagnostics + +All SqlBound diagnostics use the `SQLB` prefix. IDs below 100 are **usage** diagnostics reported +by the source generator — they validate how the attributes are used and never need a database. +IDs from 101 are **verification** diagnostics reported by the analyzer — they compare a method's +declared signature against the committed `.sqlbound/` snapshot for its SQL (see ADR 0001/0003). + +Severities are defaults; consumers can retune any of them via `.editorconfig` +(`dotnet_diagnostic.SQLB101.severity = error`). + +## Usage (`SqlBound.Usage`, generator) + +| ID | Severity | Meaning | +|----|----------|---------| +| SQLB001 | Error | `[SqlQuery]`/`[SqlExecute]` method must be a partial definition without a body | +| SQLB002 | Error | Method must be static | +| SQLB003 | Error | Method must take a `DbConnection` (or derived) first parameter | +| SQLB004 | Error | Unsupported `[SqlQuery]` return type | +| SQLB005 | Error | Row type has no supported mapping (constructor or settable properties) | +| SQLB006 | Error | Query parameter type is not supported | +| SQLB007 | Error | Command text must not be empty | +| SQLB008 | Error | Method must not be generic or nested in a generic type | +| SQLB009 | Error | `[SqlExecute]` method must return `Task` or `Task` | +| SQLB010 | Error | A method cannot carry both `[SqlQuery]` and `[SqlExecute]` | + +## Verification (`SqlBound.Verification`, analyzer) + +Reported only once the project has opted in by committing `.sqlbound/` snapshots (ADR 0003); a +project with no snapshots hears nothing. + +| ID | Severity | Meaning | +|----|----------|---------| +| SQLB101 | Warning | Query has no snapshot — run the prepare step | +| SQLB102 | Warning | Snapshot is unreadable or no longer matches the command text — re-run prepare | +| SQLB103 | Error | Statement produces no result set, but the method expects one | +| SQLB104 | Error | Result set has no column with a declared name | +| SQLB105 | Error | Column CLR type differs from the declaration | +| SQLB106 | Error | Database column is nullable but declared non-nullable (the safe converse is silent) | +| SQLB107 | Info | Result set returns columns the method never reads | +| SQLB108 | Error | Statement uses a parameter the method does not declare | +| SQLB109 | Warning | Method declares a scalar parameter the statement never uses | +| SQLB110 | Error | Parameter CLR type differs from the declaration | +| SQLB111 | Warning | `[SqlExecute]` statement returns a result set it discards | + +Two comparison rules worth knowing: + +- **Columns match by case-insensitive name, not position** — generated code binds with + `GetOrdinal(name)`, so reordering a `SELECT` list is never a mismatch. Scalar-shaped methods + verify the first column instead. +- **Types compare as mapped CLR types, not SQL type names** — SQL Server's suggested parameter + types are inferences (a comparison against `decimal(18,2)` suggests `decimal(38,19)`), so raw + SQL type comparison would false-positive.