SQL + AI
How to Build Text-to-SQL with ASP.NET Core
A working Text-to-SQL pipeline in ASP.NET Core and SQL Server: a least-privilege database identity, schema-aware prompts, Microsoft.Extensions.AI with Ollama, a ScriptDom validator, a SHOWPLAN cost check and a repair loop — and the benchmark numbers each step bought.
How to Build Text-to-SQL with ASP.NET Core
The same local model, qwen2.5-coder:7b, answering the same 32 questions against the same SQL Server
database, three times:
| Pipeline | Correct | Silently wrong | Failed | Unsafe SQL sent to the database |
|---|---|---|---|---|
| Table names + question → run whatever comes back | 17/32 (53%) | 7 | 8 | 2 (an UPDATE and a DROP TABLE) |
| + schema-aware prompt | 26/32 (81%) | 4 | 2 | 0 |
| + parser validation, cost check, repair loop | 28/32 (88%) | 4 | 0 | 0 |
The model never changed. Everything that moved the score is ordinary ASP.NET Core and SQL Server
engineering, and that's what this guide builds: an /api/ask endpoint that takes a question in English
and returns the SQL, a one-line explanation and the rows — without ever giving the model a way to
damage the database.
The code is condensed from the AI Database Agent project. The full source, with 160 tests and the benchmark, is on GitHub: AfzaalLucky/ai-database-agent.
The pipeline
question
→ build prompt (schema as DDL + rules + examples)
→ model (IChatClient → Ollama)
→ parse reply (SQL | CANNOT_ANSWER)
→ validate (ScriptDom parse + allow-list) ─┐
→ estimate cost (SET SHOWPLAN_XML ON) ├─ on error: send it back to the model, max 2 rounds
→ execute as agent_reader (row cap, timeout) ─┘
→ rowsPackages:
dotnet add package Microsoft.Data.SqlClient
dotnet add package Microsoft.Extensions.AI
dotnet add package OllamaSharp
dotnet add package Microsoft.SqlServer.TransactSql.ScriptDomBuild it in the order below. The first step is the one most demos skip, and it's the one that makes every later mistake survivable.
Step 1: A database user that can't do damage
Model-generated SQL is untrusted input. Before writing a line of C#, make it impossible for that input to do anything but read:
CREATE ROLE agent_readers AUTHORIZATION dbo;
GRANT SELECT ON SCHEMA::sales TO agent_readers;
GRANT SELECT ON SCHEMA::catalog TO agent_readers;
GRANT SELECT ON SCHEMA::inventory TO agent_readers;
GRANT SELECT ON hr.Departments TO agent_readers;
GRANT SELECT ON hr.EmployeeDirectory TO agent_readers; -- a view of hr.Employees without Salary
DENY SELECT ON hr.Employees TO agent_readers;
GRANT SHOWPLAN TO agent_readers; -- for the cost check in step 6
CREATE USER agent_reader WITHOUT LOGIN WITH DEFAULT_SCHEMA = sales;
ALTER ROLE agent_readers ADD MEMBER agent_reader;WITHOUT LOGIN means nobody can connect as agent_reader. The API connects with its own login —
which needs only CONNECT, VIEW DEFINITION and IMPERSONATE on this user — and switches identity
per query.
Hide sensitive columns with a view, not a column-level DENY. A DENY on Salary alone makes a plain
SELECT COUNT(*) FROM hr.Employees fail with error 230, which breaks ordinary questions. The view keeps
the column out of both the permissions and the schema the model sees.
This is what stopped the naïve baseline from doing harm: its UPDATE and DROP TABLE reached the
database and were refused with errors 229 and 3701.
Step 2: Run every query as that user
using System.Data;
using Microsoft.Data.SqlClient;
internal sealed class ImpersonatedSession : IAsyncDisposable
{
private readonly byte[] _cookie;
private ImpersonatedSession(SqlConnection connection, byte[] cookie)
{
Connection = connection;
_cookie = cookie;
}
public SqlConnection Connection { get; }
public static async Task<ImpersonatedSession> OpenAsync(string connectionString, string agentUser, CancellationToken ct)
{
var connection = new SqlConnection(connectionString);
try
{
await connection.OpenAsync(ct);
await using var cmd = connection.CreateCommand();
// Ad hoc batch on purpose: WITH COOKIE fails inside sp_executesql (error 15590).
// agentUser comes from configuration and is validated as a simple identifier.
cmd.CommandText = $"""
DECLARE @cookie varbinary(8000);
EXECUTE AS USER = N'{agentUser}' WITH COOKIE INTO @cookie;
SELECT @cookie;
""";
var cookie = await cmd.ExecuteScalarAsync(ct) as byte[]
?? throw new InvalidOperationException("EXECUTE AS did not return a cookie.");
return new ImpersonatedSession(connection, cookie);
}
catch
{
await connection.DisposeAsync();
throw;
}
}
public async ValueTask DisposeAsync()
{
try
{
if (Connection.State == ConnectionState.Open)
{
await using var cmd = Connection.CreateCommand();
cmd.CommandText = $"REVERT WITH COOKIE = 0x{Convert.ToHexString(_cookie)};";
await cmd.ExecuteNonQueryAsync();
}
}
catch (Exception ex) when (ex is SqlException or InvalidOperationException)
{
// Never hand a still-impersonated connection back to the pool.
SqlConnection.ClearPool(Connection);
}
finally
{
await Connection.DisposeAsync();
}
}
}Two details carry the weight here:
- The cookie.
EXECUTE AS ... WITH COOKIEmeans the generated SQL can'tREVERTits way back to the API's identity — only code holding the cookie can, and the cookie never leaves the process. - Pooling. A pooled connection that's still impersonating would hand
agent_reader(or worse, a half-reverted state) to the next request. If the revert fails, the pool is cleared.
Execution then adds a row cap and a timeout:
public async Task<QueryResult> ExecuteAsync(string sql, int maxRows, int timeoutSeconds, CancellationToken ct)
{
await using var session = await ImpersonatedSession.OpenAsync(_connectionString, "agent_reader", ct);
await using var cmd = session.Connection.CreateCommand();
cmd.CommandText = sql;
cmd.CommandTimeout = timeoutSeconds;
await using var reader = await cmd.ExecuteReaderAsync(ct);
var columns = Enumerable.Range(0, reader.FieldCount).Select(reader.GetName).ToList();
var rows = new List<object?[]>();
var truncated = false;
while (await reader.ReadAsync(ct))
{
if (rows.Count == maxRows)
{
truncated = true;
cmd.Cancel(); // stop the server producing rows nobody will read
break;
}
var row = new object?[reader.FieldCount];
for (var i = 0; i < row.Length; i++)
row[i] = reader.IsDBNull(i) ? null : reader.GetValue(i);
rows.Add(row);
}
return new QueryResult(columns, rows, truncated);
}The project uses 500 rows and 15 seconds.
Step 3: Give the model the schema it would need to write the query by hand
The naïve prompt sent only table and column names — about 300 tokens. The model guessed the rest:
LIMIT 1 instead of TOP (1), a Salary column that doesn't exist, and a discount column treated as a
fraction when it stores 0–50, which turned discounted line totals negative.
Read the real schema from the catalog views, including the MS_Description extended properties, and
send it as annotated DDL — the format code models have seen most:
SELECT s.name AS SchemaName, o.name AS ObjectName, c.name AS ColumnName,
ty.name AS TypeName, c.is_nullable,
CAST(ep.value AS nvarchar(1000)) AS Description
FROM sys.columns c
JOIN sys.objects o ON o.object_id = c.object_id
JOIN sys.schemas s ON s.schema_id = o.schema_id
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
LEFT JOIN sys.extended_properties ep
ON ep.class = 1 AND ep.major_id = c.object_id
AND ep.minor_id = c.column_id AND ep.name = N'MS_Description'
WHERE o.type IN ('U', 'V') AND o.is_ms_shipped = 0
ORDER BY s.name, o.name, c.column_id;Do the same for primary keys (sys.indexes), foreign keys (sys.foreign_keys) and CHECK
constraints (sys.check_constraints, which give you the allowed values of status columns). Then read
visibility while impersonating agent_reader, and drop anything it can't select. The model should
see exactly what the agent may query — so hr.Employees never appears, and neither does Salary.
Rendered, a table looks like this:
-- Sales orders (header).
TABLE sales.Orders (
OrderId int PRIMARY KEY,
CustomerId int NOT NULL -> sales.Customers.CustomerId,
OrderDate datetime2 NOT NULL,
Status varchar(10) NOT NULL -- values: 'Pending', 'Shipped', 'Delivered', 'Cancelled', 'Returned'
)Types, keys, join paths as -> arrows, descriptions and enum values in one compact block. Cache it —
the project re-reads the catalog every 10 minutes, not per request.
Step 4: Write the prompt, and give the model a way out
The system prompt carries the dialect rules and one instruction most demos leave out — permission to decline:
public const string SystemPrompt = """
You are an expert Microsoft SQL Server developer. You write one read-only T-SQL query that answers
the user's question using ONLY the tables and columns in the schema provided.
Rules:
- T-SQL only: use TOP (n), never LIMIT; use GETDATE(), DATEADD, DATEDIFF, DATEFROMPARTS, EOMONTH, YEAR(), MONTH().
- Always write schema-qualified table names (e.g. sales.Orders) and give tables short aliases.
- Join only along the foreign keys shown (the "->" arrows). Never guess a column that is not listed.
- For enum-like columns, compare only with the listed values, spelled exactly.
- Read the descriptions: they define business meaning (for example how revenue is calculated).
- One SELECT statement (common table expressions allowed). Never modify data.
- If the question needs data that is not in the schema, or asks to change data, do not write a query:
reply with exactly: CANNOT_ANSWER: <one short reason>
Reply format: one short sentence saying what the query returns, then the query in a ```sql block.
""";After the schema, add what the schema can't say: today's date (so "last month" means something), the currency, and business rules such as revenue excludes cancelled and returned orders. Without that rule the baseline reported January revenue as 498,209.57 instead of 469,328.68 — a number that looks fine and is wrong.
Then add a few worked examples as prior chat turns:
var messages = new List<ChatMessage> { new(ChatRole.System, SystemPrompt + "\n\n" + context) };
foreach (var ex in examples)
{
messages.Add(new ChatMessage(ChatRole.User, ex.Question));
messages.Add(new ChatMessage(ChatRole.Assistant, $"{ex.Explanation}\n```sql\n{ex.Sql}\n```"));
}
messages.Add(new ChatMessage(ChatRole.User, question));The model imitates the examples exactly — including the sentence before the SQL. An example with "Here is the query." as its explanation gets that sentence copied into every answer. Write a real one.
Step 5: Call the model through IChatClient
Microsoft.Extensions.AI keeps the pipeline provider-agnostic. Register Ollama once:
builder.Services.AddChatClient(sp =>
{
// 127.0.0.1, not localhost: on Windows localhost resolves to ::1 first and Ollama listens
// on IPv4 only, so every new connection waited ~2 s for the IPv6 attempt to fail.
var http = new HttpClient
{
BaseAddress = new Uri("http://127.0.0.1:11434"),
Timeout = TimeSpan.FromSeconds(180), // the first call also loads the model
};
return new OllamaApiClient(http, "qwen2.5-coder:7b");
})
.Use(inner => new ConfigureOptionsChatClient(inner,
// Ollama's default context is 2048 tokens: too small for a schema-aware prompt.
o => o.AddOllamaOption(OllamaOption.NumCtx, 8192)))
.UseLogging();Both comments are real findings. The context-window one is the nastier of the two: Ollama truncates the prompt without an error, so the model silently loses the start of your schema.
Swapping to OpenAI or Claude later means changing this registration, not the pipeline.
Step 6: Parse the reply
Small local models don't always follow the format: they drop the fence, drop the language tag, or wrap the SQL in prose. Parse defensively:
public sealed record ParsedResponse(string? Sql, string? Explanation, string? CannotAnswerReason);
public static partial class ModelResponseParser
{
[GeneratedRegex(@"```[ \t]*(?:sql|tsql|t-sql|mssql)?[ \t]*\r?\n(?<code>.*?)```",
RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex FencedBlock();
[GeneratedRegex(@"CANNOT_ANSWER\s*[:\-]?\s*(?<reason>[^\r\n]*)")]
private static partial Regex CannotAnswer();
[GeneratedRegex(@"^\s*(WITH|SELECT)\b", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
private static partial Regex QueryStart();
public static ParsedResponse Parse(string? response)
{
var text = (response ?? "").Trim();
var refusal = CannotAnswer().Match(text);
if (refusal.Success && !FencedBlock().IsMatch(text))
return new ParsedResponse(null, null, refusal.Groups["reason"].Value.Trim());
var fence = FencedBlock().Match(text);
if (fence.Success)
return new ParsedResponse(fence.Groups["code"].Value.Trim().TrimEnd(';'),
text.Remove(fence.Index, fence.Length).Trim(), null);
// No fence: take everything from the first line that starts a query.
var start = QueryStart().Match(text);
return start.Success
? new ParsedResponse(text[start.Index..].Trim().TrimEnd(';'), text[..start.Index].Trim(), null)
: new ParsedResponse(null, null, null);
}
}With the schema-aware prompt and this parser, the benchmark went from 53% to 81%. What remained were failures the prompt can't prevent: invented columns, columns taken from the wrong table, the occasional query that doesn't compile.
Step 7: Validate the SQL with a real T-SQL parser
Regex checks for DROP or DELETE are trivially bypassed and reject harmless queries. Microsoft ships
the parser SQL Server tooling uses — ScriptDom. Parse, require exactly one SELECT, then walk the tree
with an allow-list: known tables only, known-safe table sources only.
using Microsoft.SqlServer.TransactSql.ScriptDom;
public static class SqlValidator
{
public static IReadOnlyList<string> Validate(string sql, IReadOnlySet<string> allowedTables)
{
var parser = new TSql170Parser(initialQuotedIdentifiers: true);
var fragment = parser.Parse(new StringReader(sql), out var errors);
if (errors.Count > 0)
return errors.Take(3).Select(e => $"Syntax error at line {e.Line}, column {e.Column}: {e.Message}").ToList();
var statements = ((TSqlScript)fragment).Batches.SelectMany(b => b.Statements).ToList();
if (statements is not [SelectStatement select])
return ["Exactly one SELECT statement is allowed."];
if (select.Into is not null)
return ["SELECT ... INTO creates a table and is not allowed."];
var visitor = new AllowListVisitor(allowedTables);
select.Accept(visitor);
return visitor.Problems;
}
private sealed class AllowListVisitor(IReadOnlySet<string> allowedTables) : TSqlFragmentVisitor
{
private readonly HashSet<string> _ctes = new(StringComparer.OrdinalIgnoreCase);
public List<string> Problems { get; } = [];
public override void ExplicitVisit(SelectStatement node)
{
// Register CTE names first so references to them aren't reported as unknown tables.
foreach (var cte in node.WithCtesAndXmlNamespaces?.CommonTableExpressions ?? [])
_ctes.Add(cte.ExpressionName.Value);
base.ExplicitVisit(node);
}
public override void Visit(TableReference node)
{
if (node is not (NamedTableReference or QueryDerivedTable or JoinTableReference or JoinParenthesisTableReference))
Problems.Add($"Not allowed: table source {node.GetType().Name}."); // OPENROWSET, OPENQUERY, TVFs, ...
}
public override void Visit(NamedTableReference node)
{
var name = node.SchemaObject;
if (name.ServerIdentifier is not null || name.DatabaseIdentifier is not null)
{
Problems.Add("Not allowed: references to other databases or linked servers.");
return;
}
if (name.SchemaIdentifier is null && _ctes.Contains(name.BaseIdentifier.Value))
return;
var qualified = $"{name.SchemaIdentifier?.Value}.{name.BaseIdentifier.Value}";
if (!allowedTables.Contains(qualified))
Problems.Add($"Table '{qualified}' does not exist or is not available. Available tables: {string.Join(", ", allowedTables)}.");
}
public override void Visit(VariableReference node) =>
Problems.Add($"Not allowed: variable {node.Name}.");
public override void Visit(FunctionCall node)
{
if (node.CallTarget is not null)
Problems.Add($"Not allowed: user-defined function '{node.FunctionName.Value}'.");
}
}
}allowedTables is the schema from step 3, so it's already filtered to what agent_reader can read
(build it with StringComparer.OrdinalIgnoreCase).
The project's full validator also checks every column against the table it's taken from — and says
where the column actually lives. That came from a real failure: for "Which products are below their
reorder level in the Leeds Depot?" the model wrote p.ReorderLevel instead of s.ReorderLevel, was told
the column doesn't exist on catalog.Products, and declined — concluding the data wasn't there. The
fix was two-part: the validator now adds "It exists on inventory.Stock (alias s), which is already in
the query", and the agent refuses to accept a CANNOT_ANSWER that contradicts the validator. See
SqlValidator.cs.
The validator is the first gate. agent_reader is still the floor: if the validator has a bug, the
database refuses anyway.
Step 8: Estimate the cost before running it
A query can be valid and read-only and still be far too expensive to run. Ask SQL Server for the estimated plan without executing anything:
public async Task<double> EstimateCostAsync(string sql, CancellationToken ct)
{
await using var session = await ImpersonatedSession.OpenAsync(_connectionString, "agent_reader", ct);
await using (var on = session.Connection.CreateCommand())
{
on.CommandText = "SET SHOWPLAN_XML ON;";
await on.ExecuteNonQueryAsync(ct);
}
try
{
await using var cmd = session.Connection.CreateCommand();
cmd.CommandText = sql;
var plan = XDocument.Parse((string)(await cmd.ExecuteScalarAsync(ct))!);
return plan.Descendants()
.Where(e => e.Name.LocalName == "StmtSimple")
.Sum(e => double.Parse(e.Attribute("StatementSubTreeCost")?.Value ?? "0", CultureInfo.InvariantCulture));
}
finally
{
// Must be switched off before the session reverts: under SHOWPLAN nothing executes, REVERT included.
await using var off = session.Connection.CreateCommand();
off.CommandText = "SET SHOWPLAN_XML OFF;";
await off.ExecuteNonQueryAsync(CancellationToken.None);
}
}This is why agent_reader has GRANT SHOWPLAN. It also doubles as a compile check: an invalid column
or a type error fails here, before execution, with SQL Server's own message. The project rejects
anything above an estimated cost of 200 and tells the user to narrow the question.
Step 9: Send errors back to the model
Every rejection so far produces a precise, human-readable message. Instead of returning it to the user, return it to the model:
private static void AddRepairTurn(List<ChatMessage> messages, string reply, IReadOnlyList<string> problems)
{
var sb = new StringBuilder("That query was not accepted:\n");
foreach (var p in problems)
sb.Append("- ").Append(p).Append('\n');
sb.Append("\nWrite a corrected query using only tables and columns from the schema. ")
.Append("If a column was taken from the wrong table, take it from the table that has it (joining it if needed). ")
.Append("Reply with CANNOT_ANSWER: <reason> only if the data needed does not exist anywhere in the schema.");
messages.Add(new ChatMessage(ChatRole.Assistant, reply));
messages.Add(new ChatMessage(ChatRole.User, sb.ToString()));
}The loop around it: parse → validate → estimate → execute; any fixable failure adds a repair turn and goes round again, up to two repairs. Two exceptions:
- Security rejections are final. A write attempt isn't a typo to fix.
- Timeouts are final. Rephrasing the same query won't make it faster.
Validation, cost check and repair took the benchmark from 81% to 88%, with zero failed queries
and zero unsafe SQL executed. Repairs were rare — 1.03 attempts per question on average — so it
added no noticeable latency. The question that used to invent AVG(e.Salary) now comes back as a
correct refusal: the salary data isn't in the schema the agent can see.
Step 10: Expose it as an endpoint
builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 64 * 1024);
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddPolicy("ask", http => RateLimitPartition.GetFixedWindowLimiter(
http.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1) }));
});
var app = builder.Build();
app.UseRateLimiter();
app.MapPost("/api/ask", async (AskRequest request, TextToSqlAgent agent, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.Question))
return Results.Problem("Ask a question.", statusCode: StatusCodes.Status400BadRequest);
try
{
return Results.Ok(await agent.AskAsync(request.Question, ct));
}
catch (HttpRequestException)
{
return Results.Problem("The language model is not reachable. Is Ollama running?",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
})
.RequireRateLimiting("ask");
public sealed record AskRequest(string Question);Every /api/ask call drives a language model and a database, so it's rate-limited per client. A local
model serves one request at a time, so the project also puts a concurrency limiter in front of it.
Return the SQL alongside the rows, always. A user who can read the query can catch the answers the benchmark calls silently wrong; a user who only sees a number can't.
What this pipeline still gets wrong
The four questions V3 misses are all business definitions, not SQL:
- "last month" meaning the previous calendar month, not the last 30 days;
- average order value per order, not per order line;
- a category total that should include its subcategories;
- a "conversion rate" the database doesn't track — the model invented one.
Validation can't catch these because the SQL is valid and the rows are real. On a held-out question set the same pipeline scores 50%. Closing that gap takes a retrieved business glossary, which lifted the held-out score to 75% — covered in Part 4 of the AI Database Agent series.
Checklist
- Create a
WITHOUT LOGINdatabase user withSELECTon what may be read andSHOWPLAN. Hide sensitive columns behind a view. - Run every generated query under
EXECUTE AS USER ... WITH COOKIE, with a row cap and a command timeout. Clear the pool if the revert fails. - Send the schema as annotated DDL — types, keys, foreign keys, descriptions, allowed values — read as the agent user, cached.
- Put dialect rules, today's date and business rules in the prompt, add worked examples with real
explanations, and allow
CANNOT_ANSWER. - Register the model behind
IChatClient. With Ollama, raisenum_ctx. - Parse replies defensively: fenced, unfenced and refusals.
- Validate with ScriptDom against an allow-list, not a blocklist.
- Estimate cost with
SET SHOWPLAN_XML ONbefore executing. - Feed validation and SQL Server errors back to the model for up to two repairs; never repair a security rejection.
- Measure it. Build 30-odd questions with gold SQL and score by result set. Without the benchmark, none of the numbers in this article exist — and the silently wrong answers stay silent.
The complete implementation, setup scripts, decision records and the benchmark: AfzaalLucky/ai-database-agent. For the full build story version by version, start with Part 1 — naïve Text-to-SQL and why it fails silently.
Continue reading
Related articles
ASP.NET Core vs Python FastAPI for AI APIs: Which One Should Serve Your Model?
ASP.NET Core vs Python FastAPI for AI APIs — the same streaming LLM endpoint built in both, and a decision framework based on where your model actually runs.
Read article →AI Database Agent, Part 1: Naïve Text-to-SQL and Why It Fails Silently
The baseline every Text-to-SQL demo starts from — table names in, SQL out — measured against 32 real questions. 53% correct, seven silently wrong answers, and an UPDATE and a DROP TABLE sent to the database.
Read article →AI Database Agent, Part 2: Schema-Aware Prompting
Give the model what a human analyst would look at — types, keys, join paths, column descriptions, allowed values, business rules — as annotated DDL. Accuracy goes from 53% to 81%, and unsafe SQL executed drops to zero.
Read article →