Files
ClawdDotNet/src/ClawdDotNet.Tools.Database/SchemaLookup.cs
T
RichardandClaude Opus 5.5 a02b1996dd 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 <noreply@anthropic.com>
2026-09-25 10:13:56 +02:00

152 lines
7.6 KiB
C#

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;
}
}