using System.Text; using System.Text.RegularExpressions; using DbfDataReader; using DataConnection.Interfaces; namespace DataConnection.DB.FoxPro; /// /// Helper di basso livello per leggere database Visual FoxPro / dBase in modo /// completamente managed (nessun provider OLE DB/ODBC richiesto, funziona a 64-bit). /// /// Legge direttamente i file .dbf (tabelle), .fpt (campi memo) e, quando /// la sorgente è un container .dbc, ne legge il catalogo per ricavare l'elenco /// ufficiale delle tabelle. La libreria sottostante è DbfDataReader (MIT). /// /// Sola lettura: il coupler usa FoxPro come sorgente dati. /// public static class FoxProReader { static FoxProReader() { // Le code page non-Unicode (es. 1252, 1250, 850) non sono disponibili di default // su .NET Core/5+: serve registrare il provider. Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } /// Code page di default (Windows-1252, ANSI Europa occidentale). public const int DefaultCodePage = 1252; // Mappa byte "language driver" dell'header DBF -> code page più comuni. private static readonly Dictionary LanguageDriverCodePages = new() { [0x01] = 437, // U.S. MS-DOS [0x02] = 850, // International MS-DOS [0x03] = 1252, // Windows ANSI (più comune nei dati VFP) [0x08] = 865, // Danish OEM [0x09] = 437, // Dutch OEM [0x0A] = 850, // Dutch OEM* [0x64] = 852, // Eastern European MS-DOS [0x65] = 866, // Russian MS-DOS [0x66] = 865, // Nordic MS-DOS [0x67] = 861, // Icelandic MS-DOS [0x6A] = 737, // Greek MS-DOS [0x6B] = 857, // Turkish MS-DOS [0x57] = 1252, // ANSI [0xC8] = 1250, // Eastern European Windows [0xC9] = 1251, // Russian Windows [0xCA] = 1254, // Turkish Windows [0xCB] = 1253, // Greek Windows }; /// /// Interpreta la "connection string" FoxPro (un percorso, eventualmente con opzioni) /// e risolve cartella, eventuale .dbc, encoding e gestione record cancellati. /// /// Formati accettati: /// /// percorso nudo a un file .dbc: C:\dati\Data.dbc /// percorso nudo a una cartella di .dbf: C:\dati /// stringa con chiavi: Data Source=C:\dati\Data.dbc;CodePage=1252;IncludeDeleted=false /// stringa stile OLE DB VFP: Provider=vfpoledb;Data Source=C:\dati\Data.dbc;... (Data Source viene estratto) /// /// public static FoxProConnectionInfo Resolve(string connectionString) { if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("Percorso del database FoxPro non specificato.", nameof(connectionString)); string? path = null; int? codePage = null; bool includeDeleted = false; // Se contiene almeno una coppia chiave=valore, interpretiamo come connection string; // altrimenti l'intera stringa è il percorso. if (connectionString.Contains('=')) { foreach (var segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var idx = segment.IndexOf('='); if (idx < 0) { // Segmento senza '=': potrebbe essere un percorso nudo mescolato — usalo se non abbiamo già un path path ??= segment.Trim(); continue; } var key = segment[..idx].Trim(); var value = segment[(idx + 1)..].Trim().Trim('"', '\''); switch (key.Replace(" ", "").ToLowerInvariant()) { case "datasource": case "source": case "data": path = value; break; case "codepage": case "encoding": if (int.TryParse(value, out var cp)) codePage = cp; else codePage = TryGetCodePageByName(value); break; case "includedeleted": case "deleted": includeDeleted = value.Equals("true", StringComparison.OrdinalIgnoreCase) || value.Equals("on", StringComparison.OrdinalIgnoreCase) || value == "1"; break; // "Provider", "Collating Sequence", ecc. sono ignorati (compatibilità OLE DB) } } } else { path = connectionString.Trim(); } path = path?.Trim().Trim('"', '\''); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Impossibile determinare il percorso del database FoxPro dalla stringa fornita.", nameof(connectionString)); string folder; string? dbcPath = null; if (path.EndsWith(".dbc", StringComparison.OrdinalIgnoreCase)) { dbcPath = path; folder = Path.GetDirectoryName(path) ?? path; } else { folder = path.TrimEnd('\\', '/'); // Se la cartella contiene un singolo .dbc e nessuna ambiguità, lo usiamo come container. if (Directory.Exists(folder)) { var dbcs = Directory.GetFiles(folder, "*.dbc"); if (dbcs.Length == 1) dbcPath = dbcs[0]; } } var encoding = ResolveEncoding(codePage, dbcPath, folder); return new FoxProConnectionInfo { Folder = folder, DbcPath = dbcPath, Encoding = encoding, IncludeDeleted = includeDeleted }; } /// Verifica che la sorgente sia raggiungibile (cartella esistente, eventuale .dbc presente). public static void EnsureReachable(FoxProConnectionInfo info) { if (info.IsContainer && !File.Exists(info.DbcPath!)) throw new FileNotFoundException($"File database container (.dbc) non trovato: {info.DbcPath}"); if (!Directory.Exists(info.Folder)) throw new DirectoryNotFoundException($"Cartella del database FoxPro non trovata: {info.Folder}"); } /// /// Elenco delle tabelle: dal catalogo del .dbc (OBJECTTYPE='Table') se container, /// altrimenti enumerando i file .dbf nella cartella. Vengono restituite solo le /// tabelle effettivamente apribili (file .dbf presente). /// public static List GetTableNames(FoxProConnectionInfo info) { EnsureReachable(info); var names = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); if (info.IsContainer) { foreach (var name in ReadTableNamesFromContainer(info)) { if (seen.Add(name) && File.Exists(ResolveTableFilePath(info.Folder, name))) names.Add(name); } } else { foreach (var file in Directory.EnumerateFiles(info.Folder, "*.dbf", SearchOption.TopDirectoryOnly)) { var name = Path.GetFileNameWithoutExtension(file); if (seen.Add(name)) names.Add(name); } } names.Sort(StringComparer.OrdinalIgnoreCase); return names; } /// Schema (colonne) di una singola tabella. public static List GetTableColumns(FoxProConnectionInfo info, string tableName) { var filePath = ResolveTableFilePath(info.Folder, tableName); if (!File.Exists(filePath)) throw new FileNotFoundException($"Tabella FoxPro non trovata: {filePath}"); using var table = new DbfTable(filePath, info.Encoding); var columns = new List(); foreach (var col in table.Columns) { columns.Add(new DbColumnInfo { Name = col.ColumnName.Trim(), DataType = MapColumnType(col), IsNullable = true, // VFP: nullability non esposta in modo affidabile dall'header IsPrimaryKey = false // le tabelle .dbf non hanno una PK affidabile; la chiave si sceglie nel coupler }); } return columns; } /// /// Legge i record di una tabella come dizionari nome-colonna -> valore. /// I valori null sono rappresentati con (coerente con gli altri manager). /// Streaming: non carica l'intera tabella in memoria. /// public static IEnumerable> ReadRecords( FoxProConnectionInfo info, string tableName, int? maxRows = null) { var filePath = ResolveTableFilePath(info.Folder, tableName); if (!File.Exists(filePath)) throw new FileNotFoundException($"Tabella FoxPro non trovata: {filePath}"); var options = new DbfDataReaderOptions { Encoding = info.Encoding, SkipDeletedRecords = !info.IncludeDeleted }; using var reader = new DbfDataReader.DbfDataReader(filePath, options); // Nomi colonna (trimmati) per ordinale. var names = reader.DbfTable.Columns.Select(c => c.ColumnName.Trim()).ToArray(); long emitted = 0; while (reader.Read()) { var row = new Dictionary(names.Length, StringComparer.OrdinalIgnoreCase); for (int i = 0; i < names.Length; i++) { row[names[i]] = reader.IsDBNull(i) ? DBNull.Value : reader.GetValue(i); } yield return row; emitted++; if (maxRows.HasValue && emitted >= maxRows.Value) yield break; } } /// Numero di record (header) di una tabella, senza scorrere i dati. public static long GetRecordCount(FoxProConnectionInfo info, string tableName) { var filePath = ResolveTableFilePath(info.Folder, tableName); if (!File.Exists(filePath)) return 0; using var table = new DbfTable(filePath, info.Encoding); return table.Header.RecordCount; } /// /// Estrae il nome tabella (e un eventuale TOP n) da una semplice query SELECT. /// Gestisce anche le query "wrappate" generate da CreateLimitedQuery /// (SELECT TOP n * FROM (SELECT ... FROM tabella) AS subquery): viene preso /// l'ultimo identificatore dopo FROM, cioè la tabella reale più interna. /// public static (string Table, int? Top)? ParseSimpleSelect(string sql) { if (string.IsNullOrWhiteSpace(sql)) return null; var fromMatches = Regex.Matches(sql, @"\bFROM\s+\[?([A-Za-z_][\w]*)\]?", RegexOptions.IgnoreCase); if (fromMatches.Count == 0) return null; var table = fromMatches[^1].Groups[1].Value; int? top = null; var topMatch = Regex.Match(sql, @"\bTOP\s+(\d+)", RegexOptions.IgnoreCase); if (topMatch.Success && int.TryParse(topMatch.Groups[1].Value, out var t)) top = t; return (table, top); } /// Risolve il path del file .dbf per una tabella, gestendo il case-insensitive di Windows. public static string ResolveTableFilePath(string folder, string tableName) { var name = tableName.Trim().Trim('[', ']', '"', '\''); if (name.EndsWith(".dbf", StringComparison.OrdinalIgnoreCase)) name = name[..^4]; var direct = Path.Combine(folder, name + ".dbf"); if (File.Exists(direct)) return direct; // Ricerca case-insensitive (i cataloghi .dbc spesso usano un case diverso dai file). if (Directory.Exists(folder)) { foreach (var file in Directory.EnumerateFiles(folder, "*.dbf", SearchOption.TopDirectoryOnly)) { if (string.Equals(Path.GetFileNameWithoutExtension(file), name, StringComparison.OrdinalIgnoreCase)) return file; } } return direct; // non trovato: il chiamante gestirà il FileNotFound } private static IEnumerable ReadTableNamesFromContainer(FoxProConnectionInfo info) { // Il file .dbc è esso stesso una tabella DBF: il suo catalogo contiene righe con // OBJECTTYPE in {Database, Table, Index, Field, Relation, View}. Ci interessano i 'Table'. var options = new DbfDataReaderOptions { Encoding = info.Encoding, SkipDeletedRecords = true }; using var reader = new DbfDataReader.DbfDataReader(info.DbcPath!, options); int typeIdx = -1, nameIdx = -1; for (int i = 0; i < reader.DbfTable.Columns.Count; i++) { var col = reader.DbfTable.Columns[i].ColumnName.Trim(); if (col.Equals("OBJECTTYPE", StringComparison.OrdinalIgnoreCase)) typeIdx = i; else if (col.Equals("OBJECTNAME", StringComparison.OrdinalIgnoreCase)) nameIdx = i; } if (typeIdx < 0 || nameIdx < 0) yield break; // .dbc non nel formato atteso while (reader.Read()) { var type = reader.IsDBNull(typeIdx) ? null : reader.GetValue(typeIdx)?.ToString()?.Trim(); if (!string.Equals(type, "Table", StringComparison.OrdinalIgnoreCase)) continue; var name = reader.IsDBNull(nameIdx) ? null : reader.GetValue(nameIdx)?.ToString()?.Trim(); if (!string.IsNullOrEmpty(name)) yield return name; } } private static string MapColumnType(DbfColumn col) { return col.ColumnType switch { DbfColumnType.Character => $"Character({col.Length})", DbfColumnType.WideCharacter => $"Varchar({col.Length})", DbfColumnType.Memo => "Memo", DbfColumnType.Number => col.DecimalCount > 0 ? $"Numeric({col.Length},{col.DecimalCount})" : $"Numeric({col.Length})", DbfColumnType.Float => $"Float({col.Length},{col.DecimalCount})", DbfColumnType.Double => "Double", DbfColumnType.SignedLong => "Integer", DbfColumnType.Currency => "Currency", DbfColumnType.Date => "Date", DbfColumnType.DateTime => "DateTime", DbfColumnType.Boolean => "Logical", DbfColumnType.General => "General", _ => col.ColumnType.ToString() }; } private static Encoding ResolveEncoding(int? overrideCodePage, string? dbcPath, string folder) { // 1) Override esplicito dell'utente if (overrideCodePage.HasValue) { var enc = TryGetEncoding(overrideCodePage.Value); if (enc != null) return enc; } // 2) Auto-detect dal byte "language driver" dell'header (dbc o prima tabella della cartella) try { string? sampleDbf = dbcPath; if (sampleDbf == null && Directory.Exists(folder)) sampleDbf = Directory.EnumerateFiles(folder, "*.dbf", SearchOption.TopDirectoryOnly).FirstOrDefault(); if (sampleDbf != null && File.Exists(sampleDbf)) { using var fs = File.OpenRead(sampleDbf); var header = new DbfHeader(fs); if (LanguageDriverCodePages.TryGetValue(header.LanguageDriver, out var cp)) { var enc = TryGetEncoding(cp); if (enc != null) return enc; } } } catch { // ignora: si usa il default } // 3) Default return Encoding.GetEncoding(DefaultCodePage); } private static Encoding? TryGetEncoding(int codePage) { try { return Encoding.GetEncoding(codePage); } catch { return null; } } private static int? TryGetCodePageByName(string name) { try { return Encoding.GetEncoding(name).CodePage; } catch { return null; } } }