Merge feat/676-schemaauskunft: Schemaauskunft im Datenbank-Werkzeug (#676)

This commit is contained in:
Richard
2026-09-25 10:13:56 +02:00
5 changed files with 461 additions and 20 deletions
+9 -3
View File
@@ -347,6 +347,10 @@ im Testbetrieb nachweislich und wiederholt (#676).
**Ohne Schemaauskunft gibt es die ganze Geschichte nicht.** Sie ist damit Vorbedingung,
nicht Beiwerk — und mit #676 ohnehin schon gemeldet.
*Stand 2026-09-25:* umgesetzt. Die Aktionen `describe` und `tables` gibt es jetzt, und
Fehler bei unbekannten Spalten oder Tabellen nennen das Vorhandene. Das Werkzeug kann
außerdem SQLite. Siehe die Umsetzung am Ende dieses Abschnitts.
### Eskalation: nach oben, nie zur Seite
Eine **Eskalation** ist: „Ich komme hier nicht weiter, das muss jemand über mir
@@ -514,9 +518,11 @@ Ohne Regeln läuft so etwas heiß. Vier, die nicht verhandelbar sind:
- **Nebenbei behoben:** Der Scanner hat einen Status, den der Lauf selbst gesetzt hatte
(blockiert, abgebrochen), am Laufende mit „erledigt“ überschrieben. Damit liefen sowohl
das Eskalieren als auch der Meldeweg aus #715 ins Leere.
- **Offen:** #676 (Schemaauskunft der Datenbank) ist die Vorbedingung dafür, dass „ich finde
kein Feld“ eine Feststellung ist und keine Vermutung. Der Mechanismus steht, der
Anwendungsfall braucht noch #676.
- **Vorbedingung erfüllt (#676):** Das Datenbank-Werkzeug beantwortet die Frage nach dem
Schema selbst. `describe` nennt alle Spalten einer Tabelle und sagt ausdrücklich, dass
ein nicht genanntes Feld nicht existiert. „Unbekannte Spalte“ und „unbekannte Tabelle“
werden mit dem Vorhandenen beantwortet. Damit ist „ich finde kein Feld dafür“ eine
Feststellung.
## 6 — Die Entscheidungsinstanz an der Spitze
+9 -3
View File
@@ -235,12 +235,18 @@ Der `AgentEngine` nutzt dann die `ToolRegistry`, um für jeden Agent nur die erl
| Eigenschaft | Wert |
|---|---|
| Name | `Database` |
| NuGet | `MySqlConnector`, `MongoDB.Driver` |
| Aktionen | `query`, `insert`, `upsert` |
| NuGet | `MySqlConnector`, `Npgsql`, `Microsoft.Data.SqlClient`, `MongoDB.Driver` (SQLite über `Microsoft.Data.Sqlite` aus Core) |
| Aktionen | `query`, `insert`, `upsert`, `describe`, `tables` |
**Config-Felder:**
- `connectionString` – Datenbankverbindung (pro Agent!)
- `type` – `"mysql"` oder `"mongodb"`
- `type` – `"mysql"`, `"postgres"`, `"mssql"`, `"sqlite"` oder `"mongodb"`
**Schemaauskunft (#676):** `describe` nennt alle Spalten einer freigegebenen Tabelle.
`tables` nennt die freigegebenen Tabellen und sagt, ob es sie gibt. Scheitert ein Statement
an einer unbekannten Spalte oder Tabelle, nennt die Antwort, was vorhanden ist. Das gilt
nur für freigegebene Tabellen. Die Abfragen dafür schreibt das Werkzeug selbst, mit dem
Tabellennamen als Parameter. `PRAGMA` bleibt im Agenten-SQL verboten.
- `allowedTables` – Liste erlaubter Tabellen (Whitelist)
**Sicherheit:**
+143 -14
View File
@@ -3,6 +3,7 @@ using System.Data.Common;
using System.Text.Json;
using ClawdDotNet.Core.Tools;
using Microsoft.Data.SqlClient;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
@@ -26,17 +27,21 @@ public sealed class DatabaseTool : IAgentTool
public bool ReadsForeignContent => true;
public string Description => """
Access to a SQL database (MySQL/MariaDB, Postgres, MSSQL) or MongoDB.
Access to a SQL database (MySQL/MariaDB, Postgres, MSSQL, SQLite) or MongoDB.
Before you use a column you have not seen: action "describe" with "table" lists every column of
the table - do not guess names. Action "tables" lists the tables you may use and whether they exist.
What "describe" does not list does not exist - say so instead of guessing.
SQL: Send each statement with action "query" and "sql" - both read and write. The operation type
(read, write, schema change) is determined from the statement itself. One statement per call,
no SQL comments. Only configured allowed tables can be accessed.
Table schema: "DESCRIBE <table>" or "SHOW COLUMNS FROM <table>" - verify column names before writing.
Write statements return the number of affected rows.
Which operations (SELECT, INSERT, UPDATE, ALTER...) you can execute without confirmation is determined
by your tool configuration. Other operations will require approval.
MongoDB: action query (filter), insert (document) in an allowed collection.
MongoDB: action query (filter), insert (document) in an allowed collection; describe (collection)
lists the fields found in its documents.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -45,8 +50,12 @@ public sealed class DatabaseTool : IAgentTool
"properties": {
"action": {
"type": "string",
"enum": ["query", "insert", "upsert"],
"description": "The action to perform"
"enum": ["query", "insert", "upsert", "describe", "tables"],
"description": "The action to perform. describe: the columns of a table; tables: the tables you may use."
},
"table": {
"type": "string",
"description": "describe: the table (SQL) whose columns to list"
},
"sql": {
"type": "string",
@@ -83,6 +92,18 @@ public sealed class DatabaseTool : IAgentTool
if (string.IsNullOrWhiteSpace(type))
return null; // Konfigurationsfehler meldet die Ausfuehrung selbst
// #676: The schema questions are reads of allowed tables — the table is checked, no SQL is sent.
var action = GetString(input, "action");
if (action == "tables")
return null;
if (action == "describe" && type != "mongodb")
{
var table = GetString(input, "table");
if (string.IsNullOrWhiteSpace(table))
return "'table' is required for describe.";
return IsTableAllowed(table, context) ? null : $"Sicherheitsfehler: Zugriff auf Tabelle '{table}' ist nicht erlaubt.";
}
if (type == "mongodb")
{
var collection = GetString(input, "collection");
@@ -115,7 +136,7 @@ public sealed class DatabaseTool : IAgentTool
return GetString(input, "action") switch
{
"query" => "select",
"query" or "describe" or "tables" => "select",
"insert" => "insert",
"upsert" => "update",
_ => null
@@ -173,6 +194,10 @@ public sealed class DatabaseTool : IAgentTool
{
return await HandleMongoAsync(action, input, connectionString, context, ct);
}
if (action is "describe" or "tables")
{
return await HandleSchemaAsync(type, action, input, connectionString, context, ct);
}
else
{
return await HandleSqlAsync(type, action, input, connectionString, context, ct);
@@ -199,18 +224,24 @@ public sealed class DatabaseTool : IAgentTool
if (Verweigerung(inspection, accessLevel, context.FreigabeGeprueft) is { } verweigert)
return ToolResult.Fail(verweigert);
using DbConnection conn = type switch
{
"mysql" => new MySqlConnection(connectionString),
"postgres" => new NpgsqlConnection(connectionString),
"mssql" => new SqlConnection(connectionString),
_ => throw new NotSupportedException($"SQL Typ '{type}' wird nicht unterstützt.")
};
using var conn = Connect(type, connectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
try
{
return await RunAsync(cmd, inspection, ct);
}
catch (DbException ex) when (SchemaLookup.Classify(ex.Message).Miss != SchemaMiss.None)
{
// #676: A refusal that knows the next step names it — the columns or tables that exist.
return ToolResult.Fail($"Fehler: {ex.Message}\n{await HintAsync(conn, type, ex.Message, inspection, context, ct)}");
}
}
private static async Task<ToolResult> RunAsync(DbCommand cmd, SqlInspection inspection, CancellationToken ct)
{
// #609: Ob gelesen oder geschrieben wird, entscheidet das Statement. Vorher fuehrte
// action "query" ein INSERT per ExecuteReader aus und meldete "[]" statt der Zeilenzahl.
if (inspection.Operation == SqlOperation.Read)
@@ -237,6 +268,90 @@ public sealed class DatabaseTool : IAgentTool
}
}
private static DbConnection Connect(string type, string connectionString) => type switch
{
"mysql" => new MySqlConnection(connectionString),
"postgres" => new NpgsqlConnection(connectionString),
"mssql" => new SqlConnection(connectionString),
"sqlite" => new SqliteConnection(connectionString),
_ => throw new NotSupportedException($"SQL Typ '{type}' wird nicht unterstützt.")
};
/// <summary>
/// #676: "describe" — every column of an allowed table; "tables" — the allowed tables and
/// whether they exist. Asked of the database, never guessed.
/// </summary>
private static async Task<ToolResult> HandleSchemaAsync(
string type, string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
{
var allowed = GetAllowedTables(context);
using var conn = Connect(type, connectionString);
await conn.OpenAsync(ct);
if (action == "tables")
{
if (allowed.Count == 0)
return ToolResult.Ok("No tables are released for you (allowedTables is empty).");
var (existing, missing) = SchemaLookup.Split(allowed, await SchemaLookup.TablesAsync(conn, type, ct));
return ToolResult.Ok(
(existing.Count == 0 ? "None of the tables released for you exists in the database." : $"Tables you may use: {string.Join(", ", existing)}.") +
(missing.Count == 0 ? "" : $"\nReleased for you but not in the database: {string.Join(", ", missing)}."));
}
var table = GetString(input, "table");
if (string.IsNullOrWhiteSpace(table))
return ToolResult.Fail("'table' is required for describe.");
if (!IsTableAllowed(table, context))
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Tabelle '{table}' ist nicht erlaubt. Erlaubt sind: {string.Join(", ", allowed)}.");
var columns = await SchemaLookup.ColumnsAsync(conn, type, table, ct);
if (columns.Count == 0)
{
var (existing, _) = SchemaLookup.Split(allowed, await SchemaLookup.TablesAsync(conn, type, ct));
return ToolResult.Fail($"Table '{table}' does not exist in the database." +
(existing.Count == 0 ? "" : $" Tables you may use: {string.Join(", ", existing)}."));
}
return ToolResult.Ok(SchemaLookup.DescribeTable(SchemaLookup.StripSchema(table), columns));
}
/// <summary>
/// #676: After "unknown column" the columns of the tables in the statement; after "unknown
/// table" the allowed tables that exist. Only ever allowed tables — the guard let the
/// statement through, so every table in it is one. A hint that fails costs the hint, not the answer.
/// </summary>
private static async Task<string> HintAsync(
DbConnection conn, string type, string message, SqlInspection inspection, AgentToolContext context, CancellationToken ct)
{
try
{
var (miss, name) = SchemaLookup.Classify(message);
if (miss == SchemaMiss.UnknownColumn)
{
var lines = new List<string>();
foreach (var table in inspection.Tables.Select(SchemaLookup.StripSchema).Distinct(StringComparer.OrdinalIgnoreCase))
{
var columns = await SchemaLookup.ColumnsAsync(conn, type, table, ct);
if (columns.Count > 0)
lines.Add($"Columns of '{table}': {SchemaLookup.ColumnList(columns)}.");
}
return lines.Count == 0
? "Use action \"describe\" to see the columns."
: $"There is no column '{name}'. " + string.Join(" ", lines) + " Use one of these, or say that the field does not exist.";
}
var (existing, _) = SchemaLookup.Split(GetAllowedTables(context), await SchemaLookup.TablesAsync(conn, type, ct));
return $"There is no table '{SchemaLookup.StripSchema(name ?? "")}'. " +
(existing.Count == 0 ? "None of the tables released for you exists." : $"Tables you may use: {string.Join(", ", existing)}.");
}
catch (Exception)
{
return "Use action \"describe\" to see the columns of a table.";
}
}
private static bool IsTableAllowed(string table, AgentToolContext context)
=> GetAllowedTables(context).Contains(SchemaLookup.StripSchema(table), StringComparer.OrdinalIgnoreCase);
private async Task<ToolResult> HandleMongoAsync(string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
{
var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null;
@@ -253,6 +368,20 @@ public sealed class DatabaseTool : IAgentTool
var db = client.GetDatabase(dbName);
var collection = db.GetCollection<BsonDocument>(collectionName);
if (action == "tables")
{
return ToolResult.Ok($"Collections you may use: {string.Join(", ", GetAllowedTables(context))}.");
}
if (action == "describe")
{
// #676: MongoDB has no schema — the fields found in the documents are the next best thing.
var sample = await collection.Find(new BsonDocument()).Limit(50).ToListAsync(ct);
var fields = sample.SelectMany(d => d.Names).Distinct().ToList();
return ToolResult.Ok(sample.Count == 0
? $"Collection '{collectionName}' has no documents."
: $"Fields found in {sample.Count} document(s) of '{collectionName}': {string.Join(", ", fields)}. " +
"A collection has no fixed schema — a field missing here may still exist in older documents.");
}
if (action == "query")
{
var filterJson = input.TryGetProperty("filter", out var f) ? f.GetString() : "{}";
@@ -0,0 +1,151 @@
using System.Data.Common;
using System.Text;
using System.Text.RegularExpressions;
namespace ClawdDotNet.Tools.Database;
/// <summary>One column of a table, as the database describes it.</summary>
public sealed record ColumnInfo(string Name, string Type, bool Nullable);
/// <summary>What a failed statement was missing, read from the database's own message.</summary>
public enum SchemaMiss
{
None,
UnknownColumn,
UnknownTable
}
/// <summary>
/// #676: The schema, asked of the database — so that "there is no field for it" is a finding,
/// not a guess.
///
/// <para>Observed in the test operation (#572): agents guessed column names and got only the
/// refusal back ("Unknown column 'zeitpunkt'"). Each guess cost two or three steps, and the
/// workaround — column names written into the task text — does not scale. The organisation
/// concept goes further (section 5): without a reliable negative finding an escalation is an
/// escalation of a guess.</para>
///
/// <para>Only ever for tables the agent may use. The lookups are written here, with the table
/// name as a parameter — the agent's own SQL still passes the guard, where PRAGMA and friends
/// stay forbidden.</para>
/// </summary>
public static class SchemaLookup
{
public static async Task<IReadOnlyList<ColumnInfo>> ColumnsAsync(DbConnection conn, string type, string table, CancellationToken ct)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = type switch
{
"mysql" => "SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @t ORDER BY ORDINAL_POSITION",
"postgres" => "SELECT column_name, data_type, is_nullable FROM information_schema.columns " +
"WHERE table_schema = ANY (current_schemas(false)) AND table_name = @t ORDER BY ordinal_position",
"mssql" => "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = @t ORDER BY ORDINAL_POSITION",
"sqlite" => "SELECT name, type, CASE WHEN \"notnull\" = 1 THEN 'NO' ELSE 'YES' END FROM pragma_table_info(@t)",
_ => throw new NotSupportedException($"SQL type '{type}' is not supported.")
};
var parameter = cmd.CreateParameter();
parameter.ParameterName = "@t";
parameter.Value = StripSchema(table);
cmd.Parameters.Add(parameter);
var columns = new List<ColumnInfo>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
columns.Add(new ColumnInfo(
reader.GetString(0),
reader.IsDBNull(1) ? "" : reader.GetString(1),
!reader.IsDBNull(2) && string.Equals(reader.GetString(2), "YES", StringComparison.OrdinalIgnoreCase)));
}
return columns;
}
/// <summary>The tables (and views) the database has, in the current database or schema.</summary>
public static async Task<IReadOnlyList<string>> TablesAsync(DbConnection conn, string type, CancellationToken ct)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = type switch
{
"mysql" => "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()",
"postgres" => "SELECT table_name FROM information_schema.tables WHERE table_schema = ANY (current_schemas(false))",
"mssql" => "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES",
"sqlite" => "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'",
_ => throw new NotSupportedException($"SQL type '{type}' is not supported.")
};
var tables = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
tables.Add(reader.GetString(0));
return tables;
}
// ═══════════════════ reading the database's refusal ═══════════════════
private static readonly Regex[] UnknownColumnPatterns =
[
new(@"Unknown column '(?<n>[^']+)'", RegexOptions.IgnoreCase), // MySQL / MariaDB
new(@"column ""(?<n>[^""]+)""(?: of relation ""[^""]+"")? does not exist", RegexOptions.IgnoreCase), // Postgres
new(@"Invalid column name '(?<n>[^']+)'", RegexOptions.IgnoreCase), // SQL Server
new(@"no such column: (?<n>[A-Za-z0-9_.$]+)", RegexOptions.IgnoreCase), // SQLite
new(@"has no column named (?<n>[A-Za-z0-9_$]+)", RegexOptions.IgnoreCase) // SQLite, INSERT
];
private static readonly Regex[] UnknownTablePatterns =
[
new(@"Table '(?<n>[^']+)' doesn't exist", RegexOptions.IgnoreCase), // MySQL / MariaDB
new(@"relation ""(?<n>[^""]+)"" does not exist", RegexOptions.IgnoreCase), // Postgres
new(@"Invalid object name '(?<n>[^']+)'", RegexOptions.IgnoreCase), // SQL Server
new(@"no such table: (?<n>[A-Za-z0-9_.$]+)", RegexOptions.IgnoreCase) // SQLite
];
/// <summary>What the message says was missing, and its name as the database wrote it.</summary>
public static (SchemaMiss Miss, string? Name) Classify(string? message)
{
if (string.IsNullOrWhiteSpace(message))
return (SchemaMiss.None, null);
foreach (var pattern in UnknownColumnPatterns)
if (pattern.Match(message) is { Success: true } m)
return (SchemaMiss.UnknownColumn, m.Groups["n"].Value);
foreach (var pattern in UnknownTablePatterns)
if (pattern.Match(message) is { Success: true } m)
return (SchemaMiss.UnknownTable, m.Groups["n"].Value);
return (SchemaMiss.None, null);
}
// ═══════════════════ saying it ═══════════════════
/// <summary>"Columns of 'messwerte' (5): gemessen_um datetime, ..." — and that these are all.</summary>
public static string DescribeTable(string table, IReadOnlyList<ColumnInfo> columns)
{
if (columns.Count == 0)
return $"Table '{table}' does not exist in the database (or has no columns).";
var sb = new StringBuilder($"Columns of '{table}' ({columns.Count}):");
foreach (var column in columns)
sb.Append($"\n- {column.Name} {column.Type}{(column.Nullable ? "" : " not null")}");
sb.Append($"\nThese are all the columns '{table}' has. A field that is not listed does not exist.");
return sb.ToString();
}
public static string ColumnList(IReadOnlyList<ColumnInfo> columns)
=> string.Join(", ", columns.Select(c => c.Name));
/// <summary>The allowed tables, split into those that exist and those that do not.</summary>
public static (IReadOnlyList<string> Existing, IReadOnlyList<string> Missing) Split(
IReadOnlyCollection<string> allowed, IReadOnlyCollection<string> inDatabase)
{
var present = new HashSet<string>(inDatabase, StringComparer.OrdinalIgnoreCase);
return (allowed.Where(present.Contains).ToList(), allowed.Where(t => !present.Contains(t)).ToList());
}
/// <summary>"schema.table" → "table", as the whitelist lists names.</summary>
public static string StripSchema(string table)
{
var name = table.Trim().Trim('`', '"', '[', ']');
var dot = name.LastIndexOf('.');
return dot >= 0 ? name[(dot + 1)..].Trim('`', '"', '[', ']') : name;
}
}
@@ -0,0 +1,149 @@
using System.Text.Json;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Database;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Database;
/// <summary>
/// #676: "There is no field for it" must be a finding, not a guess. The tool describes a table,
/// lists the tables, and a refused statement names what exists instead.
/// </summary>
public sealed class SchemaLookupTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "schema-" + Guid.NewGuid().ToString("N")[..8]);
private readonly string _connectionString;
private readonly DatabaseTool _tool = new();
public SchemaLookupTests()
{
Directory.CreateDirectory(_directory);
// Without pooling the file is free again when the test ends.
_connectionString = $"Data Source={Path.Combine(_directory, "shop.db")};Pooling=False";
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE messwerte (gemessen_um TEXT NOT NULL, ort TEXT, temperatur_c REAL, luftfeuchte_pct REAL);
INSERT INTO messwerte VALUES ('2027-03-10 12:00', 'Lager', 18.5, 40);
CREATE TABLE geheim (wert TEXT);
""";
cmd.ExecuteNonQuery();
}
public void Dispose()
{
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private AgentToolContext Context(params string[] allowed) => new(
"datenleser", "inst",
new Dictionary<string, object?>
{
["type"] = "sqlite",
["connectionString"] = _connectionString,
["allowedTables"] = JsonSerializer.SerializeToElement(allowed)
},
new NullStateStore(), NullLogger.Instance, CancellationToken.None);
private Task<ToolResult> Run(object input, params string[] allowed)
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(allowed.Length == 0 ? ["messwerte", "bestellungen"] : allowed), default);
[Fact]
public async Task Describe_lists_every_column_and_says_that_is_all()
{
var result = await Run(new { action = "describe", table = "messwerte" });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("Columns of 'messwerte' (4)");
result.Content.ShouldContain("- gemessen_um TEXT not null");
result.Content.ShouldContain("A field that is not listed does not exist.");
}
[Fact]
public async Task A_table_that_is_not_released_is_not_described()
{
var result = await Run(new { action = "describe", table = "geheim" });
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldNotContain("wert TEXT", Case.Sensitive, "the schema of a forbidden table stays hidden");
}
[Fact]
public async Task A_released_table_that_does_not_exist_is_said_to_be_missing()
{
var result = await Run(new { action = "describe", table = "bestellungen" });
result.ErrorMessage!.ShouldContain("does not exist");
result.ErrorMessage.ShouldContain("Tables you may use: messwerte");
}
[Fact]
public async Task Tables_names_what_exists_and_what_is_released_but_missing()
{
var result = await Run(new { action = "tables" });
result.Content.ShouldContain("Tables you may use: messwerte.");
result.Content.ShouldContain("not in the database: bestellungen");
result.Content.ShouldNotContain("geheim");
}
[Fact]
public async Task An_unknown_column_is_answered_with_the_columns_that_exist()
{
var result = await Run(new { action = "query", sql = "SELECT zeitpunkt FROM messwerte" });
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldContain("There is no column 'zeitpunkt'");
result.ErrorMessage.ShouldContain("Columns of 'messwerte': gemessen_um, ort, temperatur_c, luftfeuchte_pct.");
}
[Fact]
public async Task An_unknown_table_is_answered_with_the_tables_that_exist()
{
var result = await Run(new { action = "query", sql = "SELECT * FROM bestellungen" });
result.ErrorMessage!.ShouldContain("There is no table 'bestellungen'");
result.ErrorMessage.ShouldContain("Tables you may use: messwerte.");
}
[Fact]
public async Task A_plain_query_still_reads()
{
var result = await Run(new { action = "query", sql = "SELECT ort, temperatur_c FROM messwerte" });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldContain("Lager");
}
[Fact]
public void Describe_is_a_read_and_is_checked_before_approval()
{
_tool.StagingAktion(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" })).ShouldBe("select");
_tool.Preflight(JsonSerializer.SerializeToElement(new { action = "describe", table = "geheim" }), Context("messwerte"))
.ShouldNotBeNull();
_tool.Preflight(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" }), Context("messwerte"))
.ShouldBeNull();
}
[Theory]
[InlineData("Unknown column 'zeitpunkt' in 'field list'", SchemaMiss.UnknownColumn, "zeitpunkt")]
[InlineData("42703: column \"zeitpunkt\" does not exist", SchemaMiss.UnknownColumn, "zeitpunkt")]
[InlineData("Invalid column name 'zeitpunkt'.", SchemaMiss.UnknownColumn, "zeitpunkt")]
[InlineData("SQLite Error 1: 'no such column: zeitpunkt'.", SchemaMiss.UnknownColumn, "zeitpunkt")]
[InlineData("Table 'shop.bestellungen' doesn't exist", SchemaMiss.UnknownTable, "shop.bestellungen")]
[InlineData("42P01: relation \"bestellungen\" does not exist", SchemaMiss.UnknownTable, "bestellungen")]
[InlineData("Invalid object name 'bestellungen'.", SchemaMiss.UnknownTable, "bestellungen")]
[InlineData("Duplicate entry '1' for key 'PRIMARY'", SchemaMiss.None, null)]
public void The_refusal_of_each_database_is_read(string message, SchemaMiss miss, string? name)
{
var (found, what) = SchemaLookup.Classify(message);
found.ShouldBe(miss);
what.ShouldBe(name);
}
}