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