From a02b1996dd17a9cd96c823cd24645aa3cfce35f9 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 25 Sep 2026 10:13:56 +0200 Subject: [PATCH] Schemaauskunft im Datenbank-Werkzeug: "kein Feld" wird eine Feststellung (#676) - Aktionen describe (alle Spalten einer freigegebenen Tabelle, mit dem Satz, dass nicht Genanntes nicht existiert) und tables (freigegebene Tabellen, vorhanden oder fehlend) - "Unknown column" / "no such table" usw. werden mit dem Vorhandenen beantwortet - MySQL, Postgres, SQL Server, SQLite; nur fuer freigegebene Tabellen - SQLite als Datenbanktyp; die Abfragen schreibt das Werkzeug selbst, PRAGMA bleibt im Agenten-SQL verboten - MongoDB: describe nennt die Felder aus den Dokumenten - describe/tables sind Lesezugriffe (Freigabeschluessel select), Vorpruefung der Tabelle Co-Authored-By: Claude Opus 5.5 --- docs/Organisation-Konzept.md | 12 +- docs/ToolDevelopmentGuide.md | 12 +- .../DatabaseTool.cs | 157 ++++++++++++++++-- .../SchemaLookup.cs | 151 +++++++++++++++++ .../Database/SchemaLookupTests.cs | 149 +++++++++++++++++ 5 files changed, 461 insertions(+), 20 deletions(-) create mode 100644 src/ClawdDotNet.Tools.Database/SchemaLookup.cs create mode 100644 tests/ClawdDotNet.Tools.Tests/Database/SchemaLookupTests.cs diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index 1598bbf..7e256c9 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -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 diff --git a/docs/ToolDevelopmentGuide.md b/docs/ToolDevelopmentGuide.md index 99afd2c..22fc7d1 100644 --- a/docs/ToolDevelopmentGuide.md +++ b/docs/ToolDevelopmentGuide.md @@ -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:** diff --git a/src/ClawdDotNet.Tools.Database/DatabaseTool.cs b/src/ClawdDotNet.Tools.Database/DatabaseTool.cs index 9d74601..8bf093a 100644 --- a/src/ClawdDotNet.Tools.Database/DatabaseTool.cs +++ b/src/ClawdDotNet.Tools.Database/DatabaseTool.cs @@ -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 " or "SHOW COLUMNS FROM
" - 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 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.") + }; + + /// + /// #676: "describe" — every column of an allowed table; "tables" — the allowed tables and + /// whether they exist. Asked of the database, never guessed. + /// + private static async Task 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)); + } + + /// + /// #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. + /// + private static async Task 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(); + 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 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(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() : "{}"; diff --git a/src/ClawdDotNet.Tools.Database/SchemaLookup.cs b/src/ClawdDotNet.Tools.Database/SchemaLookup.cs new file mode 100644 index 0000000..c972418 --- /dev/null +++ b/src/ClawdDotNet.Tools.Database/SchemaLookup.cs @@ -0,0 +1,151 @@ +using System.Data.Common; +using System.Text; +using System.Text.RegularExpressions; + +namespace ClawdDotNet.Tools.Database; + +/// One column of a table, as the database describes it. +public sealed record ColumnInfo(string Name, string Type, bool Nullable); + +/// What a failed statement was missing, read from the database's own message. +public enum SchemaMiss +{ + None, + UnknownColumn, + UnknownTable +} + +/// +/// #676: The schema, asked of the database — so that "there is no field for it" is a finding, +/// not a guess. +/// +/// 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. +/// +/// 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. +/// +public static class SchemaLookup +{ + public static async Task> 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(); + 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; + } + + /// The tables (and views) the database has, in the current database or schema. + public static async Task> 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(); + 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 '(?[^']+)'", RegexOptions.IgnoreCase), // MySQL / MariaDB + new(@"column ""(?[^""]+)""(?: of relation ""[^""]+"")? does not exist", RegexOptions.IgnoreCase), // Postgres + new(@"Invalid column name '(?[^']+)'", RegexOptions.IgnoreCase), // SQL Server + new(@"no such column: (?[A-Za-z0-9_.$]+)", RegexOptions.IgnoreCase), // SQLite + new(@"has no column named (?[A-Za-z0-9_$]+)", RegexOptions.IgnoreCase) // SQLite, INSERT + ]; + + private static readonly Regex[] UnknownTablePatterns = + [ + new(@"Table '(?[^']+)' doesn't exist", RegexOptions.IgnoreCase), // MySQL / MariaDB + new(@"relation ""(?[^""]+)"" does not exist", RegexOptions.IgnoreCase), // Postgres + new(@"Invalid object name '(?[^']+)'", RegexOptions.IgnoreCase), // SQL Server + new(@"no such table: (?[A-Za-z0-9_.$]+)", RegexOptions.IgnoreCase) // SQLite + ]; + + /// What the message says was missing, and its name as the database wrote it. + 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 ═══════════════════ + + /// "Columns of 'messwerte' (5): gemessen_um datetime, ..." — and that these are all. + public static string DescribeTable(string table, IReadOnlyList 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 columns) + => string.Join(", ", columns.Select(c => c.Name)); + + /// The allowed tables, split into those that exist and those that do not. + public static (IReadOnlyList Existing, IReadOnlyList Missing) Split( + IReadOnlyCollection allowed, IReadOnlyCollection inDatabase) + { + var present = new HashSet(inDatabase, StringComparer.OrdinalIgnoreCase); + return (allowed.Where(present.Contains).ToList(), allowed.Where(t => !present.Contains(t)).ToList()); + } + + /// "schema.table" → "table", as the whitelist lists names. + public static string StripSchema(string table) + { + var name = table.Trim().Trim('`', '"', '[', ']'); + var dot = name.LastIndexOf('.'); + return dot >= 0 ? name[(dot + 1)..].Trim('`', '"', '[', ']') : name; + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/Database/SchemaLookupTests.cs b/tests/ClawdDotNet.Tools.Tests/Database/SchemaLookupTests.cs new file mode 100644 index 0000000..801cf57 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Database/SchemaLookupTests.cs @@ -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; + +/// +/// #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. +/// +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 + { + ["type"] = "sqlite", + ["connectionString"] = _connectionString, + ["allowedTables"] = JsonSerializer.SerializeToElement(allowed) + }, + new NullStateStore(), NullLogger.Instance, CancellationToken.None); + + private Task 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); + } +}