Returning results
editThe query handler returns a TdsQueryResult, which holds the result sets, the informational messages, the number of affected rows, the output parameters and the return value, or an error. An empty TdsQueryResult is a valid answer: the command succeeds and returns no rows.
Return rows
A TdsResultSet is a list of columns and a list of rows. Each row has one value per column, in the order of the columns:
TdsQueryDelegate query = (context, cancellationToken) =>
{
var products = new TdsResultSet();
products.Columns.Add(new TdsColumn("Id", TdsColumnType.Int32, isNullable: false));
products.Columns.Add(new TdsColumn("Name", TdsColumnType.NVarChar));
products.Columns.Add(new TdsColumn("Price", TdsColumnType.Decimal));
products.Columns.Add(new TdsColumn("ReleaseDate", TdsColumnType.Date));
products.Rows.Add([1, "Keyboard", 49.90m, new DateOnly(2024, 3, 1)]);
products.Rows.Add([2, "Mouse", 19.50m, null]); // null is sent as SQL NULL
var count = new TdsResultSet();
count.Columns.Add(new TdsColumn("Count", TdsColumnType.Int32));
count.Rows.Add([2]);
var result = new TdsQueryResult();
result.InfoMessages.Add("2 products found");
result.ResultSets.Add(products);
result.ResultSets.Add(count);
return ValueTask.FromResult(result);
};- A TdsColumn has a name, a type and a nullability (
isNullable,trueby default). Declare a column as not nullable only when none of its values isnull. - A
nullvalue is a SQLNULL. A row shorter than the list of columns getsNULLfor the missing values. - Each result set in ResultSets is a result set for the client, in order.
- InfoMessages are sent before the result sets, as informational messages (severity 10), like the output of
PRINTin SQL Server.SqlClientraises them with theSqlConnection.InfoMessageevent.
The client reads them as it reads the results of SQL Server. Here, connection is a SqlConnection to the server:
var messages = new List<string>();
connection.InfoMessage += (sender, e) => messages.Add(e.Message);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM products";
await using var reader = await command.ExecuteReaderAsync();
var names = new List<string>();
while (await reader.ReadAsync())
{
names.Add(reader.GetString(reader.GetOrdinal("Name")));
}
Assert.True(await reader.NextResultAsync()); // move to the second result set
Assert.True(await reader.ReadAsync());
Assert.Equal(2, reader.GetInt32(0));
Assert.Equal(["Keyboard", "Mouse"], names);
Assert.Equal(["2 products found"], messages);Column types
The type of a column sets the SQL Server type the client receives, and how the values of the column are converted:
| TdsColumnType | SQL Server type | Values |
|---|---|---|
| TinyInt, SmallInt, Int32, Int64 | tinyint, smallint, int, bigint | Any value Convert can turn into the integer type |
| Boolean | bit | A bool, or a value Convert.ToBoolean accepts |
| Real, Double | real, float | float, double, or any convertible number |
| Decimal | decimal(38, s) | A decimal, or any convertible number |
| Money, SmallMoney | money, smallmoney | A decimal, or any convertible number |
| NVarChar | nvarchar | A string; other values are converted with the invariant culture |
| Binary | varbinary | A byte[]; other values are sent as the UTF-8 bytes of their text |
Guid | uniqueidentifier | A Guid, or its text |
| Date | date | A DateOnly, a DateTime or a DateTimeOffset (date part) |
| Time | time(7) | A TimeOnly, a TimeSpan, or a DateTime (time of day) |
DateTime | datetime | A DateTime, a DateOnly or a DateTimeOffset |
| DateTime2 | datetime2(7) | A DateTime, a DateOnly or a DateTimeOffset |
DateTimeOffset | datetimeoffset(7) | A DateTimeOffset; a DateTime gets the offset 0 |
| Xml | xml | The text of the value (ToString()), for instance a string or an XDocument |
| Json | json | A JSON string, a JsonNode, a JsonElement, a JsonDocument, or any object, serialized with System.Text.Json (not in trimmed applications) |
Variant, UserDefined, Table | nvarchar | The text of the value (ToString()) |
- The scale of a Decimal column is the largest scale of its values, so
49.90mand19.5mare sent with two decimals. A value that does not fit in 38 digits at that scale makes the whole response an error (50005). - Text and binary values of any length are sent in full, including values longer than 8,000 bytes.
jsonis a recent SQL Server type: the client must support it to read a Json column.
A value that cannot be converted to the type of its column (a text in an Int32 column, for instance) makes the server return the error 50005 "Failed to build the query response" instead of the result sets. The connection is not dropped by the server.
Return a row count
Set RecordsAffected to the number of rows an INSERT, UPDATE or DELETE changed:
TdsQueryDelegate query = (context, cancellationToken) =>
{
if (context.CommandText?.StartsWith("UPDATE", StringComparison.OrdinalIgnoreCase) == true)
{
return ValueTask.FromResult(new TdsQueryResult { RecordsAffected = 3 });
}
return ValueTask.FromResult(new TdsQueryResult());
};ExecuteNonQueryreturns it, and so doesSqlDataReader.RecordsAffectedonce the reader is closed. Data access code that checks the count, like the optimistic concurrency checks of EF Core, relies on it.- When it is
null, no count is sent andExecuteNonQueryreturns -1, as withSET NOCOUNT ON. - The rows of the result sets are not counted: SQL Server does not count the rows of a
SELECTeither.SqlClientstill reports them with theSqlCommand.StatementCompletedevent.
Return output parameters and a return value
A parameter that the client declares with the direction Output or InputOutput has IsOutput set. Send its value back with OutputParameters, and the return status of a stored procedure with ReturnValue:
TdsQueryDelegate query = (context, cancellationToken) =>
{
if (context.RequestType == TdsQueryRequestType.Rpc && string.Equals(context.ProcedureName, "CreateCustomer", StringComparison.OrdinalIgnoreCase))
{
var result = new TdsQueryResult
{
RecordsAffected = 1,
ReturnValue = 0,
};
result.OutputParameters.Add(new TdsOutputParameter("@id", TdsColumnType.Int32, 42));
return ValueTask.FromResult(result);
}
return ValueTask.FromResult(new TdsQueryResult());
};The client reads them from its parameters once the command completes; with ExecuteReader, once the reader is closed:
await using var command = connection.CreateCommand();
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "CreateCustomer";
command.Parameters.Add(new SqlParameter("@name", SqlDbType.NVarChar, 100) { Value = "Alice" });
var id = command.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Direction = ParameterDirection.Output });
var returnValue = command.Parameters.Add(new SqlParameter("@return", SqlDbType.Int) { Direction = ParameterDirection.ReturnValue });
var recordsAffected = await command.ExecuteNonQueryAsync();
Assert.Equal(1, recordsAffected);
Assert.Equal(42, id.Value);
Assert.Equal(0, returnValue.Value);- A TdsOutputParameter has a name, with or without its
@prefix, a type and a value;nullandDBNull.Valueare SQLNULL. The values are converted as the values of a column of the same type, see Column types. - The client matches the values to its parameters by name, and ignores a value whose name has no
OutputorInputOutputparameter. - ReturnValue goes to the parameter whose direction is
ReturnValue. When it isnull, that parameter keeps its value. - Output parameters also work with the commands sent through
sp_executesql, such asINSERT … ; SET @id = SCOPE_IDENTITY().
Return an error
Return TdsQueryResult.FromError to fail the command with a SQL Server-style error:
TdsQueryDelegate query = (context, cancellationToken) =>
{
if (context.CommandText?.Contains("orders", StringComparison.OrdinalIgnoreCase) == true)
{
return ValueTask.FromResult(TdsQueryResult.FromError(new TdsQueryError
{
Number = 208,
Class = 16,
Message = "Invalid object name 'orders'.",
}));
}
return ValueTask.FromResult(new TdsQueryResult());
};The client throws a SqlException with the Number, Class (severity) and State of the error; they default to 50000, 16 and 1. Use the numbers of SQL Server (208 for an unknown object, 2812 for an unknown stored procedure, …) when the client handles specific errors. When Error is set, the result sets and informational messages are not sent. Messages longer than the protocol allows are truncated.
The connection stays open after an error, as with SQL Server: the next command runs on the same connection. Like SQL Server, SqlClient closes the connection when the severity is 20 or more, so keep the default severity, 16, for the errors of a command.
Errors sent by the server
| Number | Cause |
|---|---|
| 18456 | A rejected login, unless the authentication handler sets another number. |
| 50001 | A request type that the server does not support, such as a distributed transaction request. |
| 50002 | The query handler threw an exception. |
| 50003 | No query handler is registered (ASP.NET Core hosting). |
| 50005 | The result could not be serialized: a value does not match its column type. |