Database runtime
Code: src/IntentSharp/Data/SemanticDatabase.cs, SqlSafetyChecker.cs, SchemaDiscovery.cs,
DatabaseSchema.cs.
SemanticDatabase turns a natural-language request into one parameterized SQL statement, checks it, and
runs it only if it passes. It works over any ADO.NET DbConnection.
await using var conn = new SqliteConnection("Data Source=shop.db");
var db = new SemanticDatabase(client, conn, new DatabaseOptions { DryRun = true });
var result = await db.QueryAsync("customers who ordered more than 3 times this year");
Console.WriteLine(result.Plan.Sql); // reviewed, not executed
var executed = await db.ExecutePlanAsync(result.Plan); // the safety check runs again
foreach (var row in executed.Rows) Console.WriteLine(string.Join(", ", row.Values));
Options and defaults
DatabaseOptions defaults to the safest value for every setting:
| Option | Default | Effect |
|---|---|---|
AllowWrites |
false |
Only SELECT and WITH are allowed. |
AllowedTables |
null |
Null means every discovered table; an empty list means no table at all. |
DryRun |
false |
When true, QueryAsync and UpsertAsync return the checked plan without executing. |
MaxRows |
1000 | Rows read per query; QueryResult.Truncated reports when the cap was hit. |
CommandTimeout |
30 s | ADO.NET command timeout. |
RequireParameters |
true |
Reject inlined comparison values (see the checker rules). |
SchemaCacheDuration |
10 min | How long discovered schema is reused. |
UseTransactionsForWrites |
true |
Writes run in a transaction that rolls back on a DbException. |
Methods
GetSchemaAsync(refresh)discovers and caches the schema, guarded by a semaphore.PlanAsync(intent)asks the model for aQueryPlan(Sql,Parameters,Explanation,TablesUsed) and returns it with aSqlSafetyReport. Nothing runs. The model sees only the allowed tables, the dialect name, and instructions to use@nameparameters and no trailing semicolon. A validator runsSqlSafetyCheckeron every attempt and also reports any parameter the SQL uses that has no value inParameters, so both are fed back to the model.MaxAttemptsis 3.QueryAsync(intent)plans, then throwsSemanticDatabaseExceptionwhen planning failed, the model returned an emptySql(it declined; the explanation is included), or the check failed. Otherwise it executes, or returns the plan whenDryRunis set.ExecutePlanAsync(plan)runs a plan the caller already reviewed. The safety check runs again, against the same table setPlanAsyncuses:AllowedTableswhen set (an empty list blocks every table), otherwise every discovered table.UpsertAsync(tableName, sourceData, intent)requiresAllowWrites, an existing table and, whenAllowedTablesis set, membership in it. The model maps source fields to columns by meaning and writes oneINSERT,UPDATEorMERGEfor that table. With several source rows, the prompt asks it to handle the first row only.DryRunreturns the plan. The validator on every attempt and the final check both enforce this: they runSqlSafetyCheckerwith that one table as the allowlist (other tables inAllowedTablesdo not count), and refuse any other statement kind ("UpsertAsync accepts only one INSERT, UPDATE or MERGE on table; a DELETE statement is not allowed.") and aDELETEanywhere in the text ("... ; remove the DELETE."), for example in aMERGEclause or in a second SQL Server statement.
QueryResult holds the Plan, Safety, Rows (case-insensitive dictionaries), RowsAffected,
Truncated, Executed, Planning (the AIResult from planning) and Elapsed. As<T>() maps rows to
objects through JSON.
Parameters
The checker collects parameter names from the SQL (@name, :name or $name). A prefix that follows
:, @ or a word character does not start a parameter, so PostgreSQL casts (x::int) and T-SQL system
variables (@@ROWCOUNT) are not read as parameters.
At execution each name becomes an ADO.NET parameter named @name. Its value is looked up in
QueryPlan.Parameters case-insensitively, ignoring a leading @, : or $ on the dictionary key. A name
with no entry throws SemanticDatabaseException naming the parameter before the connection is used; an
entry whose value is null binds as DBNull. JSON values are converted to CLR strings, longs, doubles and
booleans. Binding is always by @name, which some providers do not match against :name or $name in
the SQL.
Schema discovery
AdoSchemaDiscovery.DetectDialect looks at the connection's full type name. The rows are tested in
this order and the first match wins; MySql comes before SqlClient because the MySql.Data connection is
MySql.Data.MySqlClient.MySqlConnection:
| Connection type contains | Dialect | Discovery |
|---|---|---|
Sqlite |
SQLite | sqlite_master and PRAGMA table_info; includes primary keys |
MySql |
MySQL | INFORMATION_SCHEMA.COLUMNS joined to TABLES; primary keys from TABLE_CONSTRAINTS and KEY_COLUMN_USAGE |
SqlClient |
SQL Server (T-SQL) | same |
Npgsql |
PostgreSQL | same |
Oracle or anything else |
Oracle / SQL | DbConnection.GetSchema("Columns") |
The INFORMATION_SCHEMA path excludes system schemas. After reading columns it runs a second query,
INFORMATION_SCHEMA.TABLE_CONSTRAINTS joined to KEY_COLUMN_USAGE where CONSTRAINT_TYPE = 'PRIMARY KEY',
and sets ColumnSchema.IsPrimaryKey. If that query fails (a database error, or a provider that lacks the
views), keys are left unset and discovery still succeeds; nothing is logged. This path, including the key
query, is untested against live SQL Server, PostgreSQL and MySQL servers; the tests cover SQLite only.
Discovery opens and
closes the connection if it was closed. The connection string is never copied into DatabaseSchema.
Supply your own ISchemaDiscovery to the constructor to replace it.
SqlSafetyChecker: a guard, not a parser
SqlSafetyChecker.Check(sql, options, knownTables) first replaces string literals ('...', with ''
inside) by '' and removes -- and /* */ comments, so quoted text cannot fool the keyword checks.
Quoted names, "name", [name] and `name`, are kept as written, with a doubled closing character
("", ]], ``) staying inside the name. A quote or -- inside a name therefore never opens a
literal or a comment that hides the rest of the statement: in SELECT name AS "a'" FROM users; DELETE FROM users; --' the checker still sees the ; and the DELETE. StripCommentsAndStrings(sql, out count) is
this step on its own.
Databases do not all read quoting and comments the same way, and the checker reads them one way only. So syntax that one database treats as text and another as code is a violation in itself, with a message that says what to change:
| Refused | Why |
|---|---|
a backslash right before a quote inside '...' (\') or "..." (\") |
MySQL reads it as an escaped quote, so the text runs on |
a [bracketed] name that contains ', ", `, \, $, #, --, /* or */ |
PostgreSQL reads [ ] as an array subscript, so the inside is code |
-- followed by anything but whitespace |
MySQL does not read --x as a comment |
a carriage return without a line feed inside a -- comment |
PostgreSQL ends the comment there; SQLite and MySQL do not |
/*! or /*M! |
MySQL and MariaDB run the inside of an executable comment |
/* inside a block comment |
PostgreSQL and SQL Server nest block comments; SQLite and MySQL do not |
# outside quotes and comments |
MySQL starts a comment there (this also refuses SQL Server #temp tables) |
a dollar quote, $$ or $tag$, where a $ does not follow a letter, digit, _ or $ |
PostgreSQL quotes text with it; elsewhere $ is part of a name |
Then:
- Empty SQL is a violation.
- One statement only: a
;other than a trailing one is a violation. - The first word is the
StatementKind. Allowed:SELECT,WITH; plusINSERT,UPDATE,DELETE,MERGEwhenAllowWritesis true.REPLACEis never an allowed kind. - Read-only mode also rejects the data-changing words
INSERT,UPDATE,DELETE,MERGE,UPDATETEXTandWRITETEXTanywhere, andREPLACEanywhere it is not followed by((the MySQL and SQLiteREPLACE INTOstatement, not theREPLACE(...)string function). WithAllowWrites, a statement whose kind isWITHand that contains one of those words is rejected ("A statement that starts with WITH must only read data; ..."), because it would run as a query: outside a transaction and with no affected-row count. - A deny-list of keywords is always rejected:
DROP,TRUNCATE,ALTER,CREATE,GRANT,REVOKE,EXEC,EXECUTE,SHUTDOWN,ATTACH,DETACH,PRAGMA,VACUUM,REINDEX,LOAD_FILE,OUTFILE,DUMPFILE,OPENROWSET,OPENQUERY,BULK,COPY, and the SQL Server statement wordsBACKUP,DBCC,KILL,RECONFIGURE,WAITFOR,USEandDENY. Each is matched as a whole word, ignoring case, souser_idorbackup_datepasses. A separate case-insensitive pattern\b(?:sp|xp)_\w+rejects system and extended procedure names such assp_executesqlandxp_cmdshell. - Table names after
FROM,JOIN,INTO,UPDATE,MERGE INTOandDELETE FROMare checked againstAllowedTableswhenever it is non-null (an empty list makes every referenced table a violation), and againstknownTablesonly whenAllowedTablesis null. CTE names are exempt. Matching ignores brackets, quotes and backticks, and accepts a match on the last name segment. An unquotedSETafterUPDATEis not a table, so anON CONFLICT (...) DO UPDATE SETupsert (SQLite, PostgreSQL) passes. - With
RequireParameters, a statement that has aWHERE, contains string or numeric literals (outsideTOP,LIMIT,OFFSET,FETCH FIRST/NEXT), and has no parameters at all is rejected.
Limits, by design and by omission:
- It is regex-based. It rejects some valid SQL (a column named
copyorbulk) and cannot prove a statement harmless. - The SQL Server statement words are deny-listed at the cost of some valid SQL. A column or alias named
exactly
use,deny,kill,backup,dbcc,reconfigureorwaitfor, quoted or not, is refused too; names that only contain one of them, such asuser_id, pass.USEalso refuses MySQL'sUSE INDEXhint and SQL Server'sOPTION (USE HINT (...))andUSE PLANquery hints. - Tables listed after a comma (
FROM a, b) are not extracted, so onlyais checked. - The word after
FROMorUPDATEis read as a table even where it is not one:EXTRACT(YEAR FROM col),SUBSTRING(x FROM 1)and MySQL'sON DUPLICATE KEY UPDATE col = ...name a column, which then fails the table check. OnlyDO UPDATE SETis recognised. - SQL Server runs several statements written one after another without a
;, so the one-statement rule does not seeSELECT ... SELECT ...there. Every deny-listed word is still rejected anywhere in the text, and read-only mode rejects every data-changing word too, but a trailing statement made only of words on neither list is not refused for being a second statement; only the table and literal checks apply to it. - The literal rule fires only when the statement has no parameters. One parameter plus inlined literals
passes. Literals in an
INSERT ... VALUESwithoutWHEREpass. - The procedure pattern also rejects a column or alias that starts with
sp_orxp_. - Keywords and
;inside a quoted name stay visible to the checks, so a name such as"delete"or"a;b"is refused although the statement is harmless.
Its job is to make the common failure modes of model-written SQL impossible to execute by accident. Use a database account with only the permissions the application needs.