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