From 12f7f7f52d44091f4f04768fe190ef93a341ad34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 19:35:53 +0100 Subject: [PATCH 1/6] Open Phase 3 with the SqlBound.SqlServer scaffold Bump PackageVersion to 0.3.0-preview.1 (Phase 3 owns 0.3.x) and add the pilot provider project plus its unit and integration test projects, so M7's introspection work lands in the package layout planned for it. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 2 +- sqlbound.slnx | 3 ++ .../SqlBound.SqlServer.csproj | 15 ++++++++++ ...SqlBound.SqlServer.IntegrationTests.csproj | 30 +++++++++++++++++++ .../SqlBound.SqlServer.UnitTests.csproj | 29 ++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/SqlBound.SqlServer/SqlBound.SqlServer.csproj create mode 100644 test/SqlBound.SqlServer.IntegrationTests/SqlBound.SqlServer.IntegrationTests.csproj create mode 100644 test/SqlBound.SqlServer.UnitTests/SqlBound.SqlServer.UnitTests.csproj diff --git a/Directory.Build.props b/Directory.Build.props index c671bab..7653931 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.2.0 + 0.3.0-preview.1 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound diff --git a/sqlbound.slnx b/sqlbound.slnx index e8d5aad..1df1d5b 100644 --- a/sqlbound.slnx +++ b/sqlbound.slnx @@ -4,12 +4,15 @@ + + + diff --git a/src/SqlBound.SqlServer/SqlBound.SqlServer.csproj b/src/SqlBound.SqlServer/SqlBound.SqlServer.csproj new file mode 100644 index 0000000..e101e58 --- /dev/null +++ b/src/SqlBound.SqlServer/SqlBound.SqlServer.csproj @@ -0,0 +1,15 @@ + + + + SQL Server introspection and type mapping for SqlBound. + + + + + + + + + + + diff --git a/test/SqlBound.SqlServer.IntegrationTests/SqlBound.SqlServer.IntegrationTests.csproj b/test/SqlBound.SqlServer.IntegrationTests/SqlBound.SqlServer.IntegrationTests.csproj new file mode 100644 index 0000000..4dd34dd --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/SqlBound.SqlServer.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + + net10.0 + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/test/SqlBound.SqlServer.UnitTests/SqlBound.SqlServer.UnitTests.csproj b/test/SqlBound.SqlServer.UnitTests/SqlBound.SqlServer.UnitTests.csproj new file mode 100644 index 0000000..f13d211 --- /dev/null +++ b/test/SqlBound.SqlServer.UnitTests/SqlBound.SqlServer.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + + net10.0 + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + From 0a236352c61eb5c215a084b2f70f3a929715801d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 19:37:10 +0100 Subject: [PATCH 2/6] Map SQL Server system types to generator type text SqlServerTypeMap converts sp_describe_* system type names to the exact C# type text the generator's getter set supports, so the describer and the codegen agree on one type vocabulary. Types without a supported getter (datetimeoffset, time, xml, sql_variant, spatial) map to nothing by design - they must fail describe loudly rather than promise a materialization the generated code cannot perform. Co-Authored-By: Claude Fable 5 --- src/SqlBound.SqlServer/SqlServerTypeMap.cs | 34 +++++++++ .../SqlServerTypeMapTests.cs | 73 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/SqlBound.SqlServer/SqlServerTypeMap.cs create mode 100644 test/SqlBound.SqlServer.UnitTests/SqlServerTypeMapTests.cs diff --git a/src/SqlBound.SqlServer/SqlServerTypeMap.cs b/src/SqlBound.SqlServer/SqlServerTypeMap.cs new file mode 100644 index 0000000..a20872e --- /dev/null +++ b/src/SqlBound.SqlServer/SqlServerTypeMap.cs @@ -0,0 +1,34 @@ +namespace SqlBound.SqlServer; + +/// +/// Maps SQL Server system type names (as reported by sp_describe_first_result_set and +/// sp_describe_undeclared_parameters, e.g. nvarchar(50)) to the C# type text the +/// SqlBound generator uses for the corresponding +/// getter. The supported set is deliberately exactly the generator's: a SQL type outside it has +/// no reflection-free materialization path and must surface as a describe error, not a mapping. +/// +internal static class SqlServerTypeMap +{ + public static bool TryMap(string sqlTypeName, out string? clrTypeText) + { + var parenthesisIndex = sqlTypeName.IndexOf('('); + var baseName = parenthesisIndex >= 0 ? sqlTypeName[..parenthesisIndex] : sqlTypeName; + clrTypeText = baseName.Trim().ToLowerInvariant() switch + { + "bit" => "bool", + "tinyint" => "byte", + "smallint" => "short", + "int" => "int", + "bigint" => "long", + "real" => "float", + "float" => "double", + "decimal" or "numeric" or "money" or "smallmoney" => "decimal", + "char" or "varchar" or "nchar" or "nvarchar" or "text" or "ntext" => "string", + "binary" or "varbinary" or "image" or "rowversion" or "timestamp" => "byte[]", + "uniqueidentifier" => "global::System.Guid", + "date" or "smalldatetime" or "datetime" or "datetime2" => "global::System.DateTime", + _ => null, + }; + return clrTypeText is not null; + } +} diff --git a/test/SqlBound.SqlServer.UnitTests/SqlServerTypeMapTests.cs b/test/SqlBound.SqlServer.UnitTests/SqlServerTypeMapTests.cs new file mode 100644 index 0000000..8e902aa --- /dev/null +++ b/test/SqlBound.SqlServer.UnitTests/SqlServerTypeMapTests.cs @@ -0,0 +1,73 @@ +namespace SqlBound.SqlServer.UnitTests; + +public sealed class SqlServerTypeMapTests +{ + [Theory] + [InlineData("bit", "bool")] + [InlineData("tinyint", "byte")] + [InlineData("smallint", "short")] + [InlineData("int", "int")] + [InlineData("bigint", "long")] + [InlineData("real", "float")] + [InlineData("float", "double")] + [InlineData("decimal(18,2)", "decimal")] + [InlineData("numeric(10,4)", "decimal")] + [InlineData("money", "decimal")] + [InlineData("smallmoney", "decimal")] + [InlineData("char(10)", "string")] + [InlineData("varchar(50)", "string")] + [InlineData("varchar(max)", "string")] + [InlineData("nchar(10)", "string")] + [InlineData("nvarchar(50)", "string")] + [InlineData("nvarchar(max)", "string")] + [InlineData("text", "string")] + [InlineData("ntext", "string")] + [InlineData("binary(16)", "byte[]")] + [InlineData("varbinary(50)", "byte[]")] + [InlineData("varbinary(max)", "byte[]")] + [InlineData("image", "byte[]")] + [InlineData("rowversion", "byte[]")] + [InlineData("timestamp", "byte[]")] + [InlineData("uniqueidentifier", "global::System.Guid")] + [InlineData("date", "global::System.DateTime")] + [InlineData("smalldatetime", "global::System.DateTime")] + [InlineData("datetime", "global::System.DateTime")] + [InlineData("datetime2", "global::System.DateTime")] + [InlineData("datetime2(7)", "global::System.DateTime")] + public void Should_MapToGeneratorTypeText_When_SqlTypeIsSupported(string sqlTypeName, string expected) + { + var mapped = SqlServerTypeMap.TryMap(sqlTypeName, out var clrTypeText); + + Assert.True(mapped); + Assert.Equal(expected, clrTypeText); + } + + [Theory] + [InlineData("datetimeoffset(7)")] + [InlineData("time(7)")] + [InlineData("sql_variant")] + [InlineData("xml")] + [InlineData("geography")] + [InlineData("geometry")] + [InlineData("hierarchyid")] + [InlineData("sometype")] + [InlineData("")] + public void Should_RejectType_When_SqlTypeHasNoSupportedGetter(string sqlTypeName) + { + var mapped = SqlServerTypeMap.TryMap(sqlTypeName, out var clrTypeText); + + Assert.False(mapped); + Assert.Null(clrTypeText); + } + + [Theory] + [InlineData("INT", "int")] + [InlineData("NVarChar(50)", "string")] + public void Should_MapIgnoringCase_When_SqlTypeNameIsNotLowerCase(string sqlTypeName, string expected) + { + var mapped = SqlServerTypeMap.TryMap(sqlTypeName, out var clrTypeText); + + Assert.True(mapped); + Assert.Equal(expected, clrTypeText); + } +} From 63f0e6b7c357ecee371c49a5efdb6408c8429ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 19:41:10 +0100 Subject: [PATCH 3/6] Describe result columns against a live SQL Server SqlServerQueryDescriber wraps sp_describe_first_result_set: it returns each visible column's name, zero-based ordinal, reported SQL type, mapped C# type text, and nullability, and throws SqlBoundDescribeException when a column's type has no generator-supported mapping. This is the prepare-step primitive ADR 0001 reserves the database round-trip for. Integration tests run against a real SQL Server via Testcontainers, skipping locally without Docker but failing hard in CI. Co-Authored-By: Claude Fable 5 --- src/SqlBound.SqlServer/QueryDescription.cs | 34 ++++++ .../SqlBoundDescribeException.cs | 31 ++++++ .../SqlServerQueryDescriber.cs | 98 +++++++++++++++++ src/SqlBound.SqlServer/SqlServerTypeMap.cs | 4 +- .../SqlServerFixture.cs | 94 ++++++++++++++++ .../SqlServerQueryDescriberColumnTests.cs | 102 ++++++++++++++++++ 6 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 src/SqlBound.SqlServer/QueryDescription.cs create mode 100644 src/SqlBound.SqlServer/SqlBoundDescribeException.cs create mode 100644 src/SqlBound.SqlServer/SqlServerQueryDescriber.cs create mode 100644 test/SqlBound.SqlServer.IntegrationTests/SqlServerFixture.cs create mode 100644 test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberColumnTests.cs diff --git a/src/SqlBound.SqlServer/QueryDescription.cs b/src/SqlBound.SqlServer/QueryDescription.cs new file mode 100644 index 0000000..bc91379 --- /dev/null +++ b/src/SqlBound.SqlServer/QueryDescription.cs @@ -0,0 +1,34 @@ +namespace SqlBound.SqlServer; + +/// +/// The metadata SQL Server reports for a command text: its first result set's columns and its +/// undeclared parameters. This is what the prepare step snapshots and the analyzer later +/// compares against a [SqlQuery]/[SqlExecute] method's declared signature. +/// +/// The visible columns of the first result set, in ordinal order; empty when the statement produces no result set. +/// The parameters the statement uses, in ordinal order. +public sealed record QueryDescription( + IReadOnlyList Columns, + IReadOnlyList Parameters); + +/// A single result-set column as described by SQL Server. +/// Zero-based position of the column in the result set, matching ordinals. +/// The column name; empty for an unnamed expression column. +/// The SQL Server system type name as reported, including any precision/scale/length suffix (e.g. decimal(18,2)). +/// The C# type text of the generator-supported CLR type the column materializes to, without a nullability marker. +/// Whether SQL Server reports the column as nullable. +public sealed record DescribedColumn( + int Ordinal, + string Name, + string SqlTypeName, + string ClrTypeText, + bool IsNullable); + +/// A single command parameter as described by SQL Server. +/// The parameter name without the leading @, matching the C# method parameter it binds to. +/// The SQL Server system type name suggested for the parameter, including any precision/scale/length suffix. +/// The C# type text of the generator-supported CLR type the parameter binds from, without a nullability marker. +public sealed record DescribedParameter( + string Name, + string SqlTypeName, + string ClrTypeText); diff --git a/src/SqlBound.SqlServer/SqlBoundDescribeException.cs b/src/SqlBound.SqlServer/SqlBoundDescribeException.cs new file mode 100644 index 0000000..3e07aa9 --- /dev/null +++ b/src/SqlBound.SqlServer/SqlBoundDescribeException.cs @@ -0,0 +1,31 @@ +namespace SqlBound.SqlServer; + +/// +/// Thrown when SQL Server cannot describe a command text, or describes it with a type SqlBound +/// cannot materialize. Carries the offending command text so prepare-step tooling can +/// point at the query that failed. +/// +public sealed class SqlBoundDescribeException : Exception +{ + /// Initializes the exception for a describe failure detected by SqlBound itself. + /// The failure description. + /// The command text that failed to describe. + public SqlBoundDescribeException(string message, string commandText) + : base(message) + { + CommandText = commandText; + } + + /// Initializes the exception for a describe failure reported by SQL Server. + /// The failure description. + /// The command text that failed to describe. + /// The provider exception SQL Server raised. + public SqlBoundDescribeException(string message, string commandText, Exception innerException) + : base(message, innerException) + { + CommandText = commandText; + } + + /// The command text that failed to describe. + public string CommandText { get; } +} diff --git a/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs b/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs new file mode 100644 index 0000000..4031822 --- /dev/null +++ b/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs @@ -0,0 +1,98 @@ +using System.Data; +using System.Data.Common; +using Microsoft.Data.SqlClient; + +namespace SqlBound.SqlServer; + +/// +/// Describes a command text against a live SQL Server using sp_describe_first_result_set. +/// Per ADR 0001 this round-trip belongs exclusively to the CLI prepare step (or the opt-in +/// MSBuild task) — it must never run inside the Roslyn analyzer or at application runtime. +/// +public static class SqlServerQueryDescriber +{ + /// Describes 's result columns and parameters. + /// An open connection to the database to describe against. + /// The command text to describe. + /// Cancels the describe round-trips. + /// SQL Server could not describe the command, or a described type has no SqlBound-supported mapping. + public static async Task DescribeAsync( + SqlConnection connection, string commandText, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentException.ThrowIfNullOrWhiteSpace(commandText); + + var columns = await DescribeColumnsAsync(connection, commandText, cancellationToken).ConfigureAwait(false); + return new QueryDescription(columns, []); + } + + private static async Task> DescribeColumnsAsync( + SqlConnection connection, string commandText, CancellationToken cancellationToken) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = "sys.sp_describe_first_result_set"; + command.CommandType = CommandType.StoredProcedure; + command.Parameters.Add(new SqlParameter("@tsql", SqlDbType.NVarChar, -1) { Value = commandText }); + + var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await using (reader.ConfigureAwait(false)) + { + // A statement with no result set (e.g. a bare DELETE) describes as zero rows. + if (reader.FieldCount == 0) + { + return []; + } + + var isHiddenOrdinal = reader.GetOrdinal("is_hidden"); + var columnOrdinalOrdinal = reader.GetOrdinal("column_ordinal"); + var nameOrdinal = reader.GetOrdinal("name"); + var isNullableOrdinal = reader.GetOrdinal("is_nullable"); + var systemTypeNameOrdinal = reader.GetOrdinal("system_type_name"); + var userTypeNameOrdinal = reader.GetOrdinal("user_type_name"); + + var columns = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + // Hidden columns only appear in browse mode, which this call never enables; + // the guard keeps a contract change from leaking phantom columns. + if (reader.GetBoolean(isHiddenOrdinal)) + { + continue; + } + + var name = reader.IsDBNull(nameOrdinal) ? string.Empty : reader.GetString(nameOrdinal); + var sqlTypeName = ReadTypeName(reader, systemTypeNameOrdinal, userTypeNameOrdinal); + if (!SqlServerTypeMap.TryMap(sqlTypeName, out var clrTypeText)) + { + throw new SqlBoundDescribeException( + $"Result column '{name}' has SQL type '{sqlTypeName}', which SqlBound cannot materialize.", + commandText); + } + + columns.Add(new DescribedColumn( + reader.GetInt32(columnOrdinalOrdinal) - 1, + name, + sqlTypeName, + clrTypeText, + reader.GetBoolean(isNullableOrdinal))); + } + + return columns; + } + } + } + + private static string ReadTypeName(DbDataReader reader, int systemTypeNameOrdinal, int userTypeNameOrdinal) + { + if (!reader.IsDBNull(systemTypeNameOrdinal)) + { + return reader.GetString(systemTypeNameOrdinal); + } + + // CLR UDTs and alias types report no system type name; surface the user type name so the + // unsupported-type error names the actual type. + return reader.IsDBNull(userTypeNameOrdinal) ? string.Empty : reader.GetString(userTypeNameOrdinal); + } +} diff --git a/src/SqlBound.SqlServer/SqlServerTypeMap.cs b/src/SqlBound.SqlServer/SqlServerTypeMap.cs index a20872e..1f08ebd 100644 --- a/src/SqlBound.SqlServer/SqlServerTypeMap.cs +++ b/src/SqlBound.SqlServer/SqlServerTypeMap.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace SqlBound.SqlServer; /// @@ -9,7 +11,7 @@ namespace SqlBound.SqlServer; /// internal static class SqlServerTypeMap { - public static bool TryMap(string sqlTypeName, out string? clrTypeText) + public static bool TryMap(string sqlTypeName, [NotNullWhen(true)] out string? clrTypeText) { var parenthesisIndex = sqlTypeName.IndexOf('('); var baseName = parenthesisIndex >= 0 ? sqlTypeName[..parenthesisIndex] : sqlTypeName; diff --git a/test/SqlBound.SqlServer.IntegrationTests/SqlServerFixture.cs b/test/SqlBound.SqlServer.IntegrationTests/SqlServerFixture.cs new file mode 100644 index 0000000..c2bdd16 --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/SqlServerFixture.cs @@ -0,0 +1,94 @@ +using Microsoft.Data.SqlClient; +using SqlBound.SqlServer.IntegrationTests; +using Testcontainers.MsSql; + +[assembly: AssemblyFixture(typeof(SqlServerFixture))] + +namespace SqlBound.SqlServer.IntegrationTests; + +/// +/// Starts one SQL Server container (via Testcontainers) for the whole test assembly and seeds the +/// schema the describe tests introspect. When Docker is unavailable the tests skip locally with a +/// pointer to the cause, but fail hard in CI, where the container is required. +/// +public sealed class SqlServerFixture : IAsyncLifetime +{ + private readonly MsSqlContainer _container = + new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + private Exception? _startupFailure; + + public async ValueTask InitializeAsync() + { + try + { + await _container.StartAsync(); + await using var connection = new SqlConnection(_container.GetConnectionString()); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = + """ + CREATE TABLE dbo.Items ( + Id int NOT NULL PRIMARY KEY, + Name nvarchar(50) NOT NULL, + Price decimal(18,2) NULL); + + CREATE TABLE dbo.EveryType ( + BitCol bit NOT NULL, + TinyIntCol tinyint NOT NULL, + SmallIntCol smallint NOT NULL, + IntCol int NOT NULL, + BigIntCol bigint NOT NULL, + RealCol real NOT NULL, + FloatCol float NOT NULL, + DecimalCol decimal(18,2) NOT NULL, + NumericCol numeric(10,4) NOT NULL, + MoneyCol money NOT NULL, + SmallMoneyCol smallmoney NOT NULL, + CharCol char(10) NOT NULL, + VarCharCol varchar(50) NOT NULL, + VarCharMaxCol varchar(max) NOT NULL, + NCharCol nchar(10) NOT NULL, + NVarCharCol nvarchar(50) NOT NULL, + NVarCharMaxCol nvarchar(max) NOT NULL, + TextCol text NOT NULL, + NTextCol ntext NOT NULL, + BinaryCol binary(16) NOT NULL, + VarBinaryCol varbinary(max) NOT NULL, + ImageCol image NOT NULL, + RowVersionCol rowversion NOT NULL, + GuidCol uniqueidentifier NOT NULL, + DateCol date NOT NULL, + SmallDateTimeCol smalldatetime NOT NULL, + DateTimeCol datetime NOT NULL, + DateTime2Col datetime2 NOT NULL, + VariantCol sql_variant NULL, + DateTimeOffsetCol datetimeoffset NULL); + """; + await command.ExecuteNonQueryAsync(); + } + catch (Exception exception) + { + _startupFailure = exception; + } + } + + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); + + public async Task OpenConnectionAsync() + { + if (_startupFailure is not null) + { + if (Environment.GetEnvironmentVariable("CI") is "true" or "1") + { + throw new InvalidOperationException( + "The SQL Server container is required in CI.", _startupFailure); + } + + Assert.Skip($"SQL Server container unavailable (is Docker running?): {_startupFailure.Message}"); + } + + var connection = new SqlConnection(_container.GetConnectionString()); + await connection.OpenAsync(); + return connection; + } +} diff --git a/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberColumnTests.cs b/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberColumnTests.cs new file mode 100644 index 0000000..5f1c348 --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberColumnTests.cs @@ -0,0 +1,102 @@ +namespace SqlBound.SqlServer.IntegrationTests; + +public sealed class SqlServerQueryDescriberColumnTests(SqlServerFixture fixture) +{ + [Fact] + public async Task Should_DescribeNameOrdinalTypeAndNullability_When_SelectReturnsColumns() + { + await using var connection = await fixture.OpenConnectionAsync(); + + var description = await SqlServerQueryDescriber.DescribeAsync( + connection, "SELECT Id, Name, Price FROM dbo.Items", TestContext.Current.CancellationToken); + + Assert.Equal( + [ + new DescribedColumn(0, "Id", "int", "int", IsNullable: false), + new DescribedColumn(1, "Name", "nvarchar(50)", "string", IsNullable: false), + new DescribedColumn(2, "Price", "decimal(18,2)", "decimal", IsNullable: true), + ], + description.Columns); + } + + [Fact] + public async Task Should_MapEveryMappedType_When_SelectCoversWholeTypeMap() + { + await using var connection = await fixture.OpenConnectionAsync(); + + var description = await SqlServerQueryDescriber.DescribeAsync( + connection, + """ + SELECT BitCol, TinyIntCol, SmallIntCol, IntCol, BigIntCol, RealCol, FloatCol, + DecimalCol, NumericCol, MoneyCol, SmallMoneyCol, + CharCol, VarCharCol, VarCharMaxCol, NCharCol, NVarCharCol, NVarCharMaxCol, + TextCol, NTextCol, + BinaryCol, VarBinaryCol, ImageCol, RowVersionCol, + GuidCol, DateCol, SmallDateTimeCol, DateTimeCol, DateTime2Col + FROM dbo.EveryType + """, + TestContext.Current.CancellationToken); + + string[] expected = + [ + "bool", "byte", "short", "int", "long", "float", "double", + "decimal", "decimal", "decimal", "decimal", + "string", "string", "string", "string", "string", "string", + "string", "string", + "byte[]", "byte[]", "byte[]", "byte[]", + "global::System.Guid", "global::System.DateTime", "global::System.DateTime", + "global::System.DateTime", "global::System.DateTime", + ]; + Assert.Equal(expected, description.Columns.Select(column => column.ClrTypeText)); + } + + [Fact] + public async Task Should_ReturnNoColumns_When_StatementProducesNoResultSet() + { + await using var connection = await fixture.OpenConnectionAsync(); + + var description = await SqlServerQueryDescriber.DescribeAsync( + connection, "DELETE FROM dbo.Items WHERE Id = 0", TestContext.Current.CancellationToken); + + Assert.Empty(description.Columns); + } + + [Fact] + public async Task Should_ReturnEmptyColumnName_When_ExpressionHasNoAlias() + { + await using var connection = await fixture.OpenConnectionAsync(); + + var description = await SqlServerQueryDescriber.DescribeAsync( + connection, "SELECT COUNT(*) FROM dbo.Items", TestContext.Current.CancellationToken); + + var column = Assert.Single(description.Columns); + Assert.Equal(string.Empty, column.Name); + Assert.Equal("int", column.ClrTypeText); + } + + [Theory] + [InlineData("SELECT VariantCol FROM dbo.EveryType", "sql_variant")] + [InlineData("SELECT DateTimeOffsetCol FROM dbo.EveryType", "datetimeoffset")] + public async Task Should_ThrowDescribeException_When_ColumnTypeHasNoMapping( + string commandText, string sqlTypeName) + { + await using var connection = await fixture.OpenConnectionAsync(); + + var exception = await Assert.ThrowsAsync( + () => SqlServerQueryDescriber.DescribeAsync( + connection, commandText, TestContext.Current.CancellationToken)); + + Assert.Contains(sqlTypeName, exception.Message); + Assert.Equal(commandText, exception.CommandText); + } + + [Fact] + public async Task Should_ThrowOperationCanceled_When_CancellationAlreadyRequested() + { + await using var connection = await fixture.OpenConnectionAsync(); + + await Assert.ThrowsAnyAsync( + () => SqlServerQueryDescriber.DescribeAsync( + connection, "SELECT Id FROM dbo.Items", new CancellationToken(canceled: true))); + } +} From c2c68f8012642a3017b77b688a1688c62703cdc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 19:43:38 +0100 Subject: [PATCH 4/6] Describe undeclared parameters alongside result columns DescribeAsync now also wraps sp_describe_undeclared_parameters, returning each placeholder's name (stripped of '@' to match the C# parameter it binds to) with SQL Server's suggested type mapped through the same type vocabulary; unsupported suggestions throw. Suggested types are inferences from the expression context, not column lookups - a comparison against decimal(18,2) suggests the widened decimal(38,19). Co-Authored-By: Claude Fable 5 --- .../SqlServerQueryDescriber.cs | 45 ++++++++++++- .../SqlServerQueryDescriberParameterTests.cs | 63 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberParameterTests.cs diff --git a/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs b/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs index 4031822..b872fe0 100644 --- a/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs +++ b/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs @@ -23,7 +23,8 @@ public static async Task DescribeAsync( ArgumentException.ThrowIfNullOrWhiteSpace(commandText); var columns = await DescribeColumnsAsync(connection, commandText, cancellationToken).ConfigureAwait(false); - return new QueryDescription(columns, []); + var parameters = await DescribeParametersAsync(connection, commandText, cancellationToken).ConfigureAwait(false); + return new QueryDescription(columns, parameters); } private static async Task> DescribeColumnsAsync( @@ -84,6 +85,48 @@ private static async Task> DescribeColumnsAsync( } } + private static async Task> DescribeParametersAsync( + SqlConnection connection, string commandText, CancellationToken cancellationToken) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = "sys.sp_describe_undeclared_parameters"; + command.CommandType = CommandType.StoredProcedure; + command.Parameters.Add(new SqlParameter("@tsql", SqlDbType.NVarChar, -1) { Value = commandText }); + + var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await using (reader.ConfigureAwait(false)) + { + if (reader.FieldCount == 0) + { + return []; + } + + var nameOrdinal = reader.GetOrdinal("name"); + var systemTypeNameOrdinal = reader.GetOrdinal("suggested_system_type_name"); + var userTypeNameOrdinal = reader.GetOrdinal("suggested_user_type_name"); + + var parameters = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var name = reader.GetString(nameOrdinal).TrimStart('@'); + var sqlTypeName = ReadTypeName(reader, systemTypeNameOrdinal, userTypeNameOrdinal); + if (!SqlServerTypeMap.TryMap(sqlTypeName, out var clrTypeText)) + { + throw new SqlBoundDescribeException( + $"Parameter '@{name}' has suggested SQL type '{sqlTypeName}', which SqlBound cannot bind.", + commandText); + } + + parameters.Add(new DescribedParameter(name, sqlTypeName, clrTypeText)); + } + + return parameters; + } + } + } + private static string ReadTypeName(DbDataReader reader, int systemTypeNameOrdinal, int userTypeNameOrdinal) { if (!reader.IsDBNull(systemTypeNameOrdinal)) diff --git a/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberParameterTests.cs b/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberParameterTests.cs new file mode 100644 index 0000000..a1ff78e --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberParameterTests.cs @@ -0,0 +1,63 @@ +namespace SqlBound.SqlServer.IntegrationTests; + +public sealed class SqlServerQueryDescriberParameterTests(SqlServerFixture fixture) +{ + [Fact] + public async Task Should_DescribeParameterNamesAndTypesInOrder_When_CommandUsesPlaceholders() + { + await using var connection = await fixture.OpenConnectionAsync(); + + var description = await SqlServerQueryDescriber.DescribeAsync( + connection, + "SELECT Id FROM dbo.Items WHERE Id = @id AND Name = @name AND Price > @minPrice", + TestContext.Current.CancellationToken); + + // @minPrice sits in a comparison, so SQL Server suggests the widened decimal(38,19) + // rather than the column's decimal(18,2) - suggested types are inferences, not lookups. + Assert.Equal( + [ + new DescribedParameter("id", "int", "int"), + new DescribedParameter("name", "nvarchar(50)", "string"), + new DescribedParameter("minPrice", "decimal(38,19)", "decimal"), + ], + description.Parameters); + } + + [Fact] + public async Task Should_DescribeParameters_When_StatementProducesNoResultSet() + { + await using var connection = await fixture.OpenConnectionAsync(); + + var description = await SqlServerQueryDescriber.DescribeAsync( + connection, "DELETE FROM dbo.Items WHERE Id = @id", TestContext.Current.CancellationToken); + + Assert.Empty(description.Columns); + var parameter = Assert.Single(description.Parameters); + Assert.Equal(new DescribedParameter("id", "int", "int"), parameter); + } + + [Fact] + public async Task Should_ReturnEmptyParameters_When_CommandHasNoPlaceholders() + { + await using var connection = await fixture.OpenConnectionAsync(); + + var description = await SqlServerQueryDescriber.DescribeAsync( + connection, "SELECT Id FROM dbo.Items", TestContext.Current.CancellationToken); + + Assert.Empty(description.Parameters); + } + + [Fact] + public async Task Should_ThrowDescribeException_When_ParameterTypeHasNoMapping() + { + await using var connection = await fixture.OpenConnectionAsync(); + const string commandText = "SELECT IntCol FROM dbo.EveryType WHERE DateTimeOffsetCol = @moment"; + + var exception = await Assert.ThrowsAsync( + () => SqlServerQueryDescriber.DescribeAsync( + connection, commandText, TestContext.Current.CancellationToken)); + + Assert.Contains("datetimeoffset", exception.Message); + Assert.Equal(commandText, exception.CommandText); + } +} From f46bd7955b2007fa61f52bfb6f7cd62537de4d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 19:46:11 +0100 Subject: [PATCH 5/6] Wrap server describe failures in SqlBoundDescribeException Syntax errors, missing objects, temp tables, and ambiguous parameter usage all raise provider SqlExceptions from the sp_describe_* calls; DescribeAsync now rethrows them as SqlBoundDescribeException carrying the server's message and the offending command text, giving the prepare step one exception type to report per failing query. Co-Authored-By: Claude Fable 5 --- .../SqlServerQueryDescriber.cs | 14 +++- .../SqlServerQueryDescriberErrorTests.cs | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberErrorTests.cs diff --git a/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs b/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs index b872fe0..3302e29 100644 --- a/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs +++ b/src/SqlBound.SqlServer/SqlServerQueryDescriber.cs @@ -22,9 +22,17 @@ public static async Task DescribeAsync( ArgumentNullException.ThrowIfNull(connection); ArgumentException.ThrowIfNullOrWhiteSpace(commandText); - var columns = await DescribeColumnsAsync(connection, commandText, cancellationToken).ConfigureAwait(false); - var parameters = await DescribeParametersAsync(connection, commandText, cancellationToken).ConfigureAwait(false); - return new QueryDescription(columns, parameters); + try + { + var columns = await DescribeColumnsAsync(connection, commandText, cancellationToken).ConfigureAwait(false); + var parameters = await DescribeParametersAsync(connection, commandText, cancellationToken).ConfigureAwait(false); + return new QueryDescription(columns, parameters); + } + catch (SqlException exception) + { + throw new SqlBoundDescribeException( + $"SQL Server could not describe the command: {exception.Message}", commandText, exception); + } } private static async Task> DescribeColumnsAsync( diff --git a/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberErrorTests.cs b/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberErrorTests.cs new file mode 100644 index 0000000..f2f7810 --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/SqlServerQueryDescriberErrorTests.cs @@ -0,0 +1,80 @@ +using Microsoft.Data.SqlClient; + +namespace SqlBound.SqlServer.IntegrationTests; + +public sealed class SqlServerQueryDescriberErrorTests(SqlServerFixture fixture) +{ + [Fact] + public async Task Should_ThrowDescribeExceptionWithServerError_When_SqlHasSyntaxError() + { + await using var connection = await fixture.OpenConnectionAsync(); + const string commandText = "SELEC Id FROM dbo.Items"; + + var exception = await Assert.ThrowsAsync( + () => SqlServerQueryDescriber.DescribeAsync( + connection, commandText, TestContext.Current.CancellationToken)); + + Assert.Equal(commandText, exception.CommandText); + Assert.IsType(exception.InnerException); + Assert.Contains("Incorrect syntax", exception.Message); + } + + [Fact] + public async Task Should_ThrowDescribeException_When_TableDoesNotExist() + { + await using var connection = await fixture.OpenConnectionAsync(); + const string commandText = "SELECT Id FROM dbo.NoSuchTable"; + + var exception = await Assert.ThrowsAsync( + () => SqlServerQueryDescriber.DescribeAsync( + connection, commandText, TestContext.Current.CancellationToken)); + + Assert.Equal(commandText, exception.CommandText); + Assert.Contains("NoSuchTable", exception.Message); + } + + [Fact] + public async Task Should_ThrowDescribeException_When_StatementUsesTempTable() + { + await using var connection = await fixture.OpenConnectionAsync(); + const string commandText = "SELECT Id INTO #scratch FROM dbo.Items; SELECT Id FROM #scratch"; + + var exception = await Assert.ThrowsAsync( + () => SqlServerQueryDescriber.DescribeAsync( + connection, commandText, TestContext.Current.CancellationToken)); + + Assert.Equal(commandText, exception.CommandText); + } + + [Fact] + public async Task Should_ThrowDescribeException_When_ParameterUseIsAmbiguous() + { + await using var connection = await fixture.OpenConnectionAsync(); + const string commandText = "SELECT Id FROM dbo.Items WHERE Id = @p AND Name = @p"; + + var exception = await Assert.ThrowsAsync( + () => SqlServerQueryDescriber.DescribeAsync( + connection, commandText, TestContext.Current.CancellationToken)); + + Assert.Equal(commandText, exception.CommandText); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Should_ThrowArgumentException_When_CommandTextIsNullOrWhiteSpace(string? commandText) + { + await using var connection = new SqlConnection(); + + await Assert.ThrowsAnyAsync( + () => SqlServerQueryDescriber.DescribeAsync(connection, commandText!, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_ThrowArgumentNullException_When_ConnectionIsNull() + { + await Assert.ThrowsAsync( + () => SqlServerQueryDescriber.DescribeAsync(null!, "SELECT 1", TestContext.Current.CancellationToken)); + } +} From dc725a4dc21d544ab32701b2e6816f262bf30d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 19:47:21 +0100 Subject: [PATCH 6/6] Document the SQL Server describe mechanism and its limits Record how the describer maps sp_describe_* output, which failures are inherent SQL Server limits (temp tables, ambiguous parameters, widened suggested types, unsupported type set), and how the Testcontainers suite behaves with and without Docker. Co-Authored-By: Claude Fable 5 --- docs/introspection.md | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/introspection.md diff --git a/docs/introspection.md b/docs/introspection.md new file mode 100644 index 0000000..26470b3 --- /dev/null +++ b/docs/introspection.md @@ -0,0 +1,47 @@ +# SQL Server introspection + +`SqlBound.SqlServer` provides the describe machinery behind the `prepare` step (ADR 0001): given +an open `SqlConnection` and a command text, `SqlServerQueryDescriber.DescribeAsync` returns a +`QueryDescription` — the result columns and parameters SQL Server reports for that SQL. This is +the metadata the offline `.sqlbound/` snapshots (M9) will serialize and the analyzer (M8) will +compare against each `[SqlQuery]`/`[SqlExecute]` method's declared signature. + +Per ADR 0001, this round-trip runs only in the CLI `prepare` command or the opt-in MSBuild task — +never in the Roslyn analyzer and never at application runtime. + +## Mechanism + +- **Result columns** come from `sp_describe_first_result_set`: name, zero-based ordinal + (matching `DbDataReader` ordinals), the reported system type name (e.g. `decimal(18,2)`), and + nullability. A statement with no result set (a bare `INSERT`/`UPDATE`/`DELETE`) describes as + zero columns, which is exactly what `[SqlExecute]` methods expect. +- **Parameters** come from `sp_describe_undeclared_parameters`: each `@name` placeholder with the + server's *suggested* type. Names are reported without the `@` so they compare directly against + C# method parameter names. +- Both results map SQL types to C# type text through the same vocabulary the generator's getter + set supports (`int`, `string`, `decimal`, `global::System.Guid`, `byte[]`, …). A type outside + that set fails the describe with `SqlBoundDescribeException` rather than promising a + materialization the generated code cannot perform. + +## Known SQL Server limits + +These are inherent to `sp_describe_first_result_set` / `sp_describe_undeclared_parameters` and +surface as `SqlBoundDescribeException` with the server's own error text: + +- **Temp tables**: statements that create and read `#temp` tables cannot be described. +- **Ambiguous parameters**: a placeholder used in contexts implying conflicting types + (e.g. `Id = @p AND Name = @p`) fails parameter describe. +- **Suggested parameter types are inferences, not column lookups**: a comparison like + `Price > @minPrice` against a `decimal(18,2)` column suggests the widened `decimal(38,19)`. +- **Unsupported types**: `datetimeoffset`, `time`, `xml`, `sql_variant`, spatial types, and CLR + UDTs have no generator-supported reader getter yet, so columns and parameters of those types + are rejected. (`datetimeoffset`/`time` support would first need `GetFieldValue`-based getters + in the generator.) + +## Testing + +Unit tests cover the type map exhaustively. Integration tests exercise the describer against a +real SQL Server 2022 started per test run via +[Testcontainers](https://dotnet.testcontainers.org/): with Docker present (locally or in CI) they +run for real; without Docker they skip locally with an explanatory message but fail hard in CI, +so a broken CI Docker setup cannot silently disable the suite.