Merge feat/676-schemaauskunft: Schemaauskunft im Datenbank-Werkzeug (#676)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user