- 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>
152 lines
7.6 KiB
C#
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;
|
|
}
|
|
}
|