Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<PropertyGroup Label="Package metadata">
<Authors>Luís Amorim</Authors>
<PackageVersion>0.2.0</PackageVersion>
<PackageVersion>0.3.0-preview.1</PackageVersion>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/lgamorim/sqlbound</PackageProjectUrl>
<RepositoryUrl>https://github.com/lgamorim/sqlbound</RepositoryUrl>
Expand Down
47 changes: 47 additions & 0 deletions docs/introspection.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions sqlbound.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
</Folder>
<Folder Name="/src/">
<Project Path="src/SqlBound.Generators/SqlBound.Generators.csproj" />
<Project Path="src/SqlBound.SqlServer/SqlBound.SqlServer.csproj" />
<Project Path="src/SqlBound/SqlBound.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/SqlBound.AotSmokeTest/SqlBound.AotSmokeTest.csproj" />
<Project Path="test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj" />
<Project Path="test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj" />
<Project Path="test/SqlBound.SqlServer.IntegrationTests/SqlBound.SqlServer.IntegrationTests.csproj" />
<Project Path="test/SqlBound.SqlServer.UnitTests/SqlBound.SqlServer.UnitTests.csproj" />
<Project Path="test/SqlBound.UnitTests/SqlBound.UnitTests.csproj" />
</Folder>
</Solution>
34 changes: 34 additions & 0 deletions src/SqlBound.SqlServer/QueryDescription.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace SqlBound.SqlServer;

/// <summary>
/// The metadata SQL Server reports for a command text: its first result set's columns and its
/// undeclared parameters. This is what the <c>prepare</c> step snapshots and the analyzer later
/// compares against a <c>[SqlQuery]</c>/<c>[SqlExecute]</c> method's declared signature.
/// </summary>
/// <param name="Columns">The visible columns of the first result set, in ordinal order; empty when the statement produces no result set.</param>
/// <param name="Parameters">The parameters the statement uses, in ordinal order.</param>
public sealed record QueryDescription(
IReadOnlyList<DescribedColumn> Columns,
IReadOnlyList<DescribedParameter> Parameters);

/// <summary>A single result-set column as described by SQL Server.</summary>
/// <param name="Ordinal">Zero-based position of the column in the result set, matching <see cref="System.Data.Common.DbDataReader"/> ordinals.</param>
/// <param name="Name">The column name; empty for an unnamed expression column.</param>
/// <param name="SqlTypeName">The SQL Server system type name as reported, including any precision/scale/length suffix (e.g. <c>decimal(18,2)</c>).</param>
/// <param name="ClrTypeText">The C# type text of the generator-supported CLR type the column materializes to, without a nullability marker.</param>
/// <param name="IsNullable">Whether SQL Server reports the column as nullable.</param>
public sealed record DescribedColumn(
int Ordinal,
string Name,
string SqlTypeName,
string ClrTypeText,
bool IsNullable);

/// <summary>A single command parameter as described by SQL Server.</summary>
/// <param name="Name">The parameter name without the leading <c>@</c>, matching the C# method parameter it binds to.</param>
/// <param name="SqlTypeName">The SQL Server system type name suggested for the parameter, including any precision/scale/length suffix.</param>
/// <param name="ClrTypeText">The C# type text of the generator-supported CLR type the parameter binds from, without a nullability marker.</param>
public sealed record DescribedParameter(
string Name,
string SqlTypeName,
string ClrTypeText);
15 changes: 15 additions & 0 deletions src/SqlBound.SqlServer/SqlBound.SqlServer.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>SQL Server introspection and type mapping for SqlBound.</Description>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="SqlBound.SqlServer.UnitTests" />
</ItemGroup>

</Project>
31 changes: 31 additions & 0 deletions src/SqlBound.SqlServer/SqlBoundDescribeException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace SqlBound.SqlServer;

/// <summary>
/// Thrown when SQL Server cannot describe a command text, or describes it with a type SqlBound
/// cannot materialize. Carries the offending command text so <c>prepare</c>-step tooling can
/// point at the query that failed.
/// </summary>
public sealed class SqlBoundDescribeException : Exception
{
/// <summary>Initializes the exception for a describe failure detected by SqlBound itself.</summary>
/// <param name="message">The failure description.</param>
/// <param name="commandText">The command text that failed to describe.</param>
public SqlBoundDescribeException(string message, string commandText)
: base(message)
{
CommandText = commandText;
}

/// <summary>Initializes the exception for a describe failure reported by SQL Server.</summary>
/// <param name="message">The failure description.</param>
/// <param name="commandText">The command text that failed to describe.</param>
/// <param name="innerException">The provider exception SQL Server raised.</param>
public SqlBoundDescribeException(string message, string commandText, Exception innerException)
: base(message, innerException)
{
CommandText = commandText;
}

/// <summary>The command text that failed to describe.</summary>
public string CommandText { get; }
}
149 changes: 149 additions & 0 deletions src/SqlBound.SqlServer/SqlServerQueryDescriber.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using System.Data;
using System.Data.Common;
using Microsoft.Data.SqlClient;

namespace SqlBound.SqlServer;

/// <summary>
/// Describes a command text against a live SQL Server using <c>sp_describe_first_result_set</c>.
/// Per ADR 0001 this round-trip belongs exclusively to the CLI <c>prepare</c> step (or the opt-in
/// MSBuild task) — it must never run inside the Roslyn analyzer or at application runtime.
/// </summary>
public static class SqlServerQueryDescriber
{
/// <summary>Describes <paramref name="commandText"/>'s result columns and parameters.</summary>
/// <param name="connection">An open connection to the database to describe against.</param>
/// <param name="commandText">The command text to describe.</param>
/// <param name="cancellationToken">Cancels the describe round-trips.</param>
/// <exception cref="SqlBoundDescribeException">SQL Server could not describe the command, or a described type has no SqlBound-supported mapping.</exception>
public static async Task<QueryDescription> DescribeAsync(
SqlConnection connection, string commandText, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(commandText);

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<IReadOnlyList<DescribedColumn>> 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<DescribedColumn>();
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 async Task<IReadOnlyList<DescribedParameter>> 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<DescribedParameter>();
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))
{
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);
}
}
36 changes: 36 additions & 0 deletions src/SqlBound.SqlServer/SqlServerTypeMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Diagnostics.CodeAnalysis;

namespace SqlBound.SqlServer;

/// <summary>
/// Maps SQL Server system type names (as reported by <c>sp_describe_first_result_set</c> and
/// <c>sp_describe_undeclared_parameters</c>, e.g. <c>nvarchar(50)</c>) to the C# type text the
/// SqlBound generator uses for the corresponding <see cref="System.Data.Common.DbDataReader"/>
/// 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.
/// </summary>
internal static class SqlServerTypeMap
{
public static bool TryMap(string sqlTypeName, [NotNullWhen(true)] 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks />
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\SqlBound.SqlServer\SqlBound.SqlServer.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="Testcontainers.MsSql" Version="4.13.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>

</Project>
Loading
Loading