diff --git a/CredentialManager/Integration/DataConnectionHelper.cs b/CredentialManager/Integration/DataConnectionHelper.cs index 3b4e4a1..e99608e 100644 --- a/CredentialManager/Integration/DataConnectionHelper.cs +++ b/CredentialManager/Integration/DataConnectionHelper.cs @@ -82,22 +82,28 @@ public static class DataConnectionHelper { var errors = new List(); + // Sorgenti file-based (percorso, senza host/utente/password/porta) + bool isFileBased = credential.DatabaseType == DatabaseType.Sqlite + || credential.DatabaseType == DatabaseType.Foxpro; + if (string.IsNullOrWhiteSpace(credential.Name)) errors.Add("Il nome della credenziale è obbligatorio"); - if (string.IsNullOrWhiteSpace(credential.Host) && credential.DatabaseType != DatabaseType.Sqlite) + if (string.IsNullOrWhiteSpace(credential.Host) && !isFileBased) errors.Add("L'host è obbligatorio per questo tipo di database"); if (string.IsNullOrWhiteSpace(credential.DatabaseName)) - errors.Add("Il nome del database è obbligatorio"); + errors.Add(credential.DatabaseType == DatabaseType.Foxpro + ? "Il percorso del database FoxPro (.dbc o cartella .dbf) è obbligatorio" + : "Il nome del database è obbligatorio"); - if (string.IsNullOrWhiteSpace(credential.Username) && credential.DatabaseType != DatabaseType.Sqlite) + if (string.IsNullOrWhiteSpace(credential.Username) && !isFileBased) errors.Add("Il nome utente è obbligatorio per questo tipo di database"); - if (string.IsNullOrWhiteSpace(credential.Password) && credential.DatabaseType != DatabaseType.Sqlite) + if (string.IsNullOrWhiteSpace(credential.Password) && !isFileBased) errors.Add("La password è obbligatoria per questo tipo di database"); - if (credential.Port <= 0 && credential.DatabaseType != DatabaseType.Sqlite) + if (credential.Port <= 0 && !isFileBased) { // Assegna porta predefinita se non specificata credential.Port = GetDefaultPort(credential.DatabaseType); diff --git a/CredentialManager/Models/CredentialModels.cs b/CredentialManager/Models/CredentialModels.cs index 36e00d0..715cb2f 100644 --- a/CredentialManager/Models/CredentialModels.cs +++ b/CredentialManager/Models/CredentialModels.cs @@ -58,7 +58,8 @@ public enum DatabaseType DB2, SapHana, Odbc, - OleDb + OleDb, + Foxpro } /// @@ -200,6 +201,7 @@ public static class ConnectionStringBuilder DatabaseType.SapHana => BuildSapHanaConnectionString(credential), DatabaseType.Odbc => BuildOdbcConnectionString(credential), DatabaseType.OleDb => BuildOleDbConnectionString(credential), + DatabaseType.Foxpro => BuildFoxproConnectionString(credential), _ => throw new NotSupportedException($"Database type {credential.DatabaseType} not supported") }; } private static string BuildSqlServerConnectionString(DatabaseCredential credential) @@ -474,6 +476,37 @@ public static class ConnectionStringBuilder return string.Join(";", builder); } + /// + /// Costruisce la "connection string" per una sorgente Visual FoxPro / dBase. + /// FoxPro non usa un vero connection string: serve solo il percorso (file .dbc o cartella di .dbf), + /// salvato in . Le opzioni encoding/record cancellati + /// vengono aggiunte come chiavi CodePage/IncludeDeleted, interpretate da FoxProReader. + /// + private static string BuildFoxproConnectionString(DatabaseCredential credential) + { + // Una connection string esplicita (es. incollata dall'utente) ha la precedenza. + if (!string.IsNullOrEmpty(credential.ConnectionString)) + return credential.ConnectionString; + + var path = !string.IsNullOrEmpty(credential.DatabaseName) + ? credential.DatabaseName + : credential.Host; + + var builder = new List { $"Data Source={path}" }; + + if (credential.AdditionalParameters != null) + { + if (credential.AdditionalParameters.TryGetValue("Encoding", out var enc) && !string.IsNullOrWhiteSpace(enc)) + builder.Add($"CodePage={enc}"); + + if (credential.AdditionalParameters.TryGetValue("IncludeDeleted", out var del) && + del.Equals("true", StringComparison.OrdinalIgnoreCase)) + builder.Add("IncludeDeleted=true"); + } + + return string.Join(";", builder); + } + private static void AddAdditionalParameters(List builder, Dictionary? additionalParams) { if (additionalParams != null) diff --git a/DataConnection/CredentialManagement/Models/CredentialExtensions.cs b/DataConnection/CredentialManagement/Models/CredentialExtensions.cs index ea6028e..713ea9e 100644 --- a/DataConnection/CredentialManagement/Models/CredentialExtensions.cs +++ b/DataConnection/CredentialManagement/Models/CredentialExtensions.cs @@ -23,6 +23,7 @@ public static class CredentialExtensions CredentialManager.Models.DatabaseType.SapHana => DataConnection.Enums.DatabaseType.SapHana, CredentialManager.Models.DatabaseType.Odbc => DataConnection.Enums.DatabaseType.Odbc, CredentialManager.Models.DatabaseType.OleDb => DataConnection.Enums.DatabaseType.OleDb, + CredentialManager.Models.DatabaseType.Foxpro => DataConnection.Enums.DatabaseType.Foxpro, _ => throw new NotSupportedException($"Database type {credentialDbType} not supported") }; } @@ -43,6 +44,7 @@ public static class CredentialExtensions DataConnection.Enums.DatabaseType.SapHana => CredentialManager.Models.DatabaseType.SapHana, DataConnection.Enums.DatabaseType.Odbc => CredentialManager.Models.DatabaseType.Odbc, DataConnection.Enums.DatabaseType.OleDb => CredentialManager.Models.DatabaseType.OleDb, + DataConnection.Enums.DatabaseType.Foxpro => CredentialManager.Models.DatabaseType.Foxpro, _ => throw new NotSupportedException($"Database type {dataConnectionDbType} not supported") }; } diff --git a/DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs b/DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs index 40808ca..cf8885e 100644 --- a/DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs +++ b/DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs @@ -266,6 +266,7 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService CredentialManager.Models.DatabaseType.Sqlite => await TestSqliteConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Odbc => await TestOdbcConnection(connectionString, credential), CredentialManager.Models.DatabaseType.OleDb => await TestOleDbConnection(connectionString, credential), + CredentialManager.Models.DatabaseType.Foxpro => await TestFoxproConnection(connectionString, credential), _ => (false, $"Test di connessione non implementato per {credential.DatabaseType}") }; } @@ -459,6 +460,40 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService } } + private async Task<(bool Success, string Message)> TestFoxproConnection(string connectionString, DatabaseCredential credential) + { + try + { + return await Task.Run(() => + { + var info = DataConnection.DB.FoxPro.FoxProReader.Resolve(connectionString); + DataConnection.DB.FoxPro.FoxProReader.EnsureReachable(info); + + var tables = DataConnection.DB.FoxPro.FoxProReader.GetTableNames(info); + + var details = new System.Text.StringBuilder(); + details.AppendLine("Connessione Visual FoxPro riuscita! (lettura managed, 64-bit)"); + details.AppendLine(); + details.AppendLine("Dettagli:"); + details.AppendLine(info.IsContainer + ? $"- Database container: {info.DbcPath}" + : $"- Cartella tabelle libere: {info.Folder}"); + details.AppendLine($"- Tabelle rilevate: {tables.Count}"); + details.AppendLine($"- Encoding: code page {info.Encoding.CodePage}"); + details.AppendLine($"- Record cancellati: {(info.IncludeDeleted ? "inclusi" : "esclusi")}"); + details.AppendLine("- Modalità: sola lettura (sorgente dati)"); + + return tables.Count > 0 + ? (true, details.ToString()) + : (false, $"Percorso raggiungibile ma nessuna tabella .dbf trovata in: {info.Folder}"); + }); + } + catch (Exception ex) + { + return (false, $"Errore Visual FoxPro: {ex.Message}"); + } + } + public async Task<(bool Success, string Message)> TestRestApiConnectionAsync(string credentialName) { try diff --git a/DataConnection/DB/EF/DatabaseSchemaProviderFactory.cs b/DataConnection/DB/EF/DatabaseSchemaProviderFactory.cs index b93fae0..d59dd24 100644 --- a/DataConnection/DB/EF/DatabaseSchemaProviderFactory.cs +++ b/DataConnection/DB/EF/DatabaseSchemaProviderFactory.cs @@ -22,6 +22,7 @@ public class DatabaseSchemaProviderFactory DatabaseType.SqlServer => new SqlServerSchemaProvider(), DatabaseType.Odbc => new OdbcSchemaProvider(), DatabaseType.OleDb => new OleDbSchemaProvider(), + DatabaseType.Foxpro => new FoxProSchemaProvider(), // Aggiungere qui altri provider quando implementati // DatabaseType.MySql => new MySqlSchemaProvider(), // DatabaseType.PostgreSql => new PostgreSqlSchemaProvider(), diff --git a/DataConnection/DB/EF/SchemaProviders/FoxProSchemaProvider.cs b/DataConnection/DB/EF/SchemaProviders/FoxProSchemaProvider.cs new file mode 100644 index 0000000..b5312fc --- /dev/null +++ b/DataConnection/DB/EF/SchemaProviders/FoxProSchemaProvider.cs @@ -0,0 +1,54 @@ +using DataConnection.DB.FoxPro; +using DataConnection.Interfaces; + +namespace DataConnection.EF.SchemaProviders; + +/// +/// Provider di schema per database Visual FoxPro / dBase, completamente managed +/// (legge i file .dbf/.fpt/.dbc senza provider OLE DB/ODBC). +/// La "connection string" è il percorso al .dbc o alla cartella di tabelle libere. +/// +public class FoxProSchemaProvider : IDatabaseSchemaProvider +{ + public Task>> GetDatabaseSchemaAsync(string connectionString) + { + var info = FoxProReader.Resolve(connectionString); + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var table in FoxProReader.GetTableNames(info)) + { + try + { + var columns = FoxProReader.GetTableColumns(info, table); + if (columns.Count > 0) + result[table] = columns; + } + catch (Exception ex) + { + // Una tabella corrotta/illeggibile non deve bloccare l'intera discovery. + Console.WriteLine($"[FoxPro] Impossibile leggere lo schema della tabella {table}: {ex.Message}"); + } + } + + return Task.FromResult>>(result); + } + + public Task> GetTableNamesAsync(string connectionString) + { + var info = FoxProReader.Resolve(connectionString); + return Task.FromResult>(FoxProReader.GetTableNames(info)); + } + + public Task> GetTableSchemaAsync(string connectionString, string tableName) + { + var info = FoxProReader.Resolve(connectionString); + return Task.FromResult>(FoxProReader.GetTableColumns(info, tableName)); + } + + public Task> GetAvailableDatabasesAsync(string connectionString) + { + // Sorgente file-based: un solo "database" (il container o la cartella). + var info = FoxProReader.Resolve(connectionString); + return Task.FromResult>(new[] { info.DisplayName }); + } +} diff --git a/DataConnection/DB/Enums/DatabaseType.cs b/DataConnection/DB/Enums/DatabaseType.cs index 6bdb81d..b8d6e3f 100644 --- a/DataConnection/DB/Enums/DatabaseType.cs +++ b/DataConnection/DB/Enums/DatabaseType.cs @@ -13,5 +13,6 @@ public enum DatabaseType DB2, SapHana, Odbc, - OleDb + OleDb, + Foxpro } diff --git a/DataConnection/DB/FoxPro/FoxProConnectionInfo.cs b/DataConnection/DB/FoxPro/FoxProConnectionInfo.cs new file mode 100644 index 0000000..d6afbd0 --- /dev/null +++ b/DataConnection/DB/FoxPro/FoxProConnectionInfo.cs @@ -0,0 +1,35 @@ +using System.Text; + +namespace DataConnection.DB.FoxPro; + +/// +/// Informazioni di connessione risolte per una sorgente Visual FoxPro / dBase. +/// Una "connessione" FoxPro è semplicemente un percorso sul filesystem: +/// un file .dbc (database container) oppure una cartella di tabelle libere .dbf. +/// +public sealed class FoxProConnectionInfo +{ + /// Cartella che contiene i file .dbf (e relativi .fpt/.cdx). + public string Folder { get; init; } = string.Empty; + + /// Percorso completo del file .dbc, se la sorgente è un database container; altrimenti null. + public string? DbcPath { get; init; } + + /// + /// Encoding usato per decodificare i campi carattere/memo. Il valore reale è sempre + /// impostato da (default effettivo: code page 1252); + /// qui usiamo Latin1 come placeholder built-in che non richiede il provider code-page. + /// + public Encoding Encoding { get; init; } = Encoding.Latin1; + + /// Se true, include anche i record marcati come cancellati (default: false). + public bool IncludeDeleted { get; init; } + + /// True se la sorgente è un database container (.dbc). + public bool IsContainer => !string.IsNullOrEmpty(DbcPath); + + /// Nome leggibile della sorgente (nome del .dbc o della cartella), per messaggi/log. + public string DisplayName => IsContainer + ? System.IO.Path.GetFileName(DbcPath!) + : new System.IO.DirectoryInfo(Folder).Name; +} diff --git a/DataConnection/DB/FoxPro/FoxProReader.cs b/DataConnection/DB/FoxPro/FoxProReader.cs new file mode 100644 index 0000000..059fb98 --- /dev/null +++ b/DataConnection/DB/FoxPro/FoxProReader.cs @@ -0,0 +1,422 @@ +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; } + } +} diff --git a/DataConnection/DB/FoxProDatabaseManager.cs b/DataConnection/DB/FoxProDatabaseManager.cs new file mode 100644 index 0000000..00e689a --- /dev/null +++ b/DataConnection/DB/FoxProDatabaseManager.cs @@ -0,0 +1,137 @@ +using System.Linq.Expressions; +using DataConnection.DB.FoxPro; +using DataConnection.Interfaces; + +namespace DataConnection.DB; + +/// +/// Database manager per sorgenti Visual FoxPro / dBase in modalità completamente +/// managed: legge direttamente i file .dbf/.fpt/.dbc tramite la +/// libreria DbfDataReader, senza alcun provider OLE DB/ODBC e funzionando a 64-bit. +/// +/// Sola lettura: FoxPro è supportato come sorgente dati. Le operazioni di +/// scrittura lanciano — scrivere nei .dbf rischierebbe +/// di corrompere indici .cdx e memo .fpt dei gestionali legacy. +/// +/// La "connection string" è semplicemente un percorso: un file .dbc (container) oppure +/// una cartella di tabelle libere .dbf. Vedi . +/// +public class FoxProDatabaseManager : IDatabaseManager +{ + private const string ReadOnlyMessage = + "Visual FoxPro è supportato in sola lettura (sorgente dati). La scrittura sui file .dbf non è disponibile."; + + private readonly FoxProConnectionInfo _info; + + public FoxProDatabaseManager(string connectionString) + { + _info = FoxProReader.Resolve(connectionString); + } + + public async Task TestConnectionAsync() + { + try + { + await Task.Run(() => + { + FoxProReader.EnsureReachable(_info); + // Forza la lettura dell'elenco tabelle: conferma che cartella/.dbc siano leggibili. + _ = FoxProReader.GetTableNames(_info); + }); + return true; + } + catch + { + return false; + } + } + + public Task> GetTableNamesAsync() + => Task.Run(() => (IEnumerable)FoxProReader.GetTableNames(_info)); + + public Task> GetTableSchemaAsync(string tableName) + => Task.Run(() => (IEnumerable)FoxProReader.GetTableColumns(_info, tableName)); + + public Task>> GetDatabaseSchemaAsync() + { + return Task.Run(() => + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var table in FoxProReader.GetTableNames(_info)) + { + try + { + var columns = FoxProReader.GetTableColumns(_info, table); + if (columns.Count > 0) + result[table] = columns; + } + catch (Exception ex) + { + Console.WriteLine($"[FoxPro] Schema non leggibile per {table}: {ex.Message}"); + } + } + return (IDictionary>)result; + }); + } + + public Task>> GetAllRecordsAsync(string tableName) + => Task.Run(() => (IEnumerable>)FoxProReader.ReadRecords(_info, tableName).ToList()); + + public Task>> ExecuteRawQueryAsync(string sql, string databaseName = "", params object[] parameters) + { + return Task.Run(() => + { + var parsed = FoxProReader.ParseSimpleSelect(sql); + if (parsed == null) + throw new NotSupportedException( + "Per le sorgenti FoxPro è supportato solo 'SELECT [TOP n] * FROM '. " + + "Impossibile interpretare la query: " + sql); + + var (table, top) = parsed.Value; + return FoxProReader.ReadRecords(_info, table, top).ToList(); + }); + } + + public Task GetPrimaryKeyFieldAsync(string tableName) + // Le tabelle .dbf non espongono una PK affidabile: la chiave si seleziona nel coupler. + => Task.FromResult(null); + + public Task> GetAvailableDatabasesAsync() + => Task.FromResult(new List { _info.DisplayName }); + + public Task ChangeDatabaseAsync(string databaseName) + { + // Sorgente file-based: il database è definito dal percorso. Nessun cambio a runtime. + return Task.CompletedTask; + } + + // ===== Operazioni non supportate (sola lettura) ===== + + public Task ExecuteCommandAsync(string sql, params object[] parameters) + => throw new NotSupportedException(ReadOnlyMessage); + + public Task UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary record) + => throw new NotSupportedException(ReadOnlyMessage); + + // ===== Operazioni LINQ/tipizzate non applicabili a una sorgente DBF ===== + + public Task> GetAsync( + Expression>? filter = null, + Func, IOrderedQueryable>? orderBy = null, + string includeProperties = "", + int? skip = null, + int? take = null) where T : class + => throw new NotSupportedException("GetAsync con LINQ non è supportato per FoxPro. Usare GetAllRecordsAsync."); + + public Task GetByIdAsync(object id) where T : class + => throw new NotSupportedException("GetByIdAsync non è supportato per FoxPro."); + + public Task> ExecuteQueryAsync(string sql, params object[] parameters) where T : class + => throw new NotSupportedException("ExecuteQueryAsync tipizzato non è supportato per FoxPro. Usare ExecuteRawQueryAsync."); + + public void Dispose() + { + // Nessuna risorsa persistente: ogni operazione apre/chiude i file. + GC.SuppressFinalize(this); + } +} diff --git a/DataConnection/DataConnection.csproj b/DataConnection/DataConnection.csproj index a82e5af..b9b619b 100644 --- a/DataConnection/DataConnection.csproj +++ b/DataConnection/DataConnection.csproj @@ -18,6 +18,9 @@ + + diff --git a/Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs b/Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs index 108f007..aeb996e 100644 --- a/Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs +++ b/Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs @@ -90,6 +90,19 @@ public partial class DataCoupler : ComponentBase var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential); return credential?.DatabaseType == DatabaseType.OleDb; } + + /// + /// Verifica se la credenziale database selezionata è di tipo Visual FoxPro + /// (sorgente file-based in sola lettura). + /// + protected bool IsFoxproConnection() + { + if (string.IsNullOrEmpty(selectedDatabaseCredential)) + return false; + + var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential); + return credential?.DatabaseType == DatabaseType.Foxpro; + } /// /// Gestisce il cambio di credenziale database selezionata @@ -760,6 +773,7 @@ public partial class DataCoupler : ComponentBase { DatabaseType.SqlServer => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery", DatabaseType.OleDb => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery", + DatabaseType.Foxpro => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery", DatabaseType.Oracle => $"SELECT * FROM ({baseQuery}) WHERE ROWNUM <= {limit}", DatabaseType.MySql => $"{baseQuery} LIMIT {limit}", DatabaseType.PostgreSql => $"{baseQuery} LIMIT {limit}", diff --git a/Data_Coupler/Pages/CredentialManagement.razor b/Data_Coupler/Pages/CredentialManagement.razor index 60c1115..e013ef1 100644 --- a/Data_Coupler/Pages/CredentialManagement.razor +++ b/Data_Coupler/Pages/CredentialManagement.razor @@ -244,6 +244,7 @@ else *@ + @@ -609,6 +610,76 @@ else } + else if (currentDatabaseCredential.DatabaseType == CredentialManager.Models.DatabaseType.Foxpro) + { + +
+
+
Configurazione Visual FoxPro
+
+
+
+ Lettura managed dei file .dbf/.fpt/.dbc: + funziona a 64-bit senza installare alcun provider. FoxPro è utilizzabile come + sorgente (sola lettura). +
+ +
+ + + + Database container: percorso completo al file .dbc + (es. C:\Users\aless\Desktop\data\Data.dbc).
+ Tabelle libere: percorso della cartella che contiene i file .dbf + (es. C:\Users\aless\Desktop\data). +
+
+ +
+
+
+ + + Per dati italiani lasciare 1252. +
+
+
+
+ +
+ + +
+ Di default i record cancellati vengono esclusi. +
+
+
+ +
+ Tipi VFP supportati: Character, Memo, Numeric, Float, Double, + Integer, Currency, Date, DateTime, Logical. I campi memo (.fpt) e gli indici + (.cdx) vengono rilevati automaticamente accanto al .dbf. +
+ +
+ + +
+
+
+ } else { @@ -1035,6 +1106,10 @@ else private string oleDbCollatingSequence = string.Empty; private string oleDbDeleted = string.Empty; + // Visual FoxPro specific state + private string foxproCodePage = string.Empty; + private bool foxproIncludeDeleted = false; + protected override async Task OnInitializedAsync() { await RefreshCredentials(); CheckForProblematicCredentials(); @@ -1131,7 +1206,15 @@ else if (currentDatabaseCredential.AdditionalParameters?.ContainsKey("DELETED") == true) oleDbDeleted = currentDatabaseCredential.AdditionalParameters["DELETED"]; } - + + // Se è Visual FoxPro, ripristina encoding e gestione record cancellati + if (currentDatabaseCredential.DatabaseType == DatabaseType.Foxpro) + { + foxproCodePage = currentDatabaseCredential.AdditionalParameters?.GetValueOrDefault("Encoding") ?? string.Empty; + var incDel = currentDatabaseCredential.AdditionalParameters?.GetValueOrDefault("IncludeDeleted", "") ?? ""; + foxproIncludeDeleted = string.Equals(incDel, "true", StringComparison.OrdinalIgnoreCase); + } + showDatabaseModal = true; } @@ -1155,6 +1238,26 @@ else currentDatabaseCredential.AdditionalParameters.Remove("DELETED"); } + // Sincronizza i parametri Visual FoxPro negli AdditionalParameters + if (currentDatabaseCredential.DatabaseType == DatabaseType.Foxpro) + { + currentDatabaseCredential.AdditionalParameters ??= new Dictionary(); + + if (!string.IsNullOrEmpty(foxproCodePage)) + currentDatabaseCredential.AdditionalParameters["Encoding"] = foxproCodePage; + else + currentDatabaseCredential.AdditionalParameters.Remove("Encoding"); + + if (foxproIncludeDeleted) + currentDatabaseCredential.AdditionalParameters["IncludeDeleted"] = "true"; + else + currentDatabaseCredential.AdditionalParameters.Remove("IncludeDeleted"); + + // Sorgente file-based: nessun host/porta/credenziali + currentDatabaseCredential.Host = string.Empty; + currentDatabaseCredential.Port = 0; + } + await CredentialService.SaveDatabaseCredentialAsync(currentDatabaseCredential); await JSRuntime.InvokeVoidAsync("alert", "Credenziale database salvata con successo!"); CloseDatabaseModal(); @@ -1227,6 +1330,15 @@ else } } } + else if (currentDatabaseCredential.DatabaseType == DatabaseType.Foxpro) + { + // Visual FoxPro: sorgente file-based, richiede solo il percorso (DatabaseName) + if (string.IsNullOrWhiteSpace(currentDatabaseCredential.DatabaseName)) + { + await JSRuntime.InvokeVoidAsync("alert", "Inserisci il percorso del database FoxPro (file .dbc o cartella con i .dbf)."); + return; + } + } else { // Altri database: validazione standard (Host, Username, Password) @@ -1281,7 +1393,15 @@ else { await LoadOdbcData(); } - + + // Se è Visual FoxPro, reimposta i valori di default (sola lettura, file-based) + if (currentDatabaseCredential.DatabaseType == DatabaseType.Foxpro) + { + currentDatabaseCredential.AdditionalParameters ??= new Dictionary(); + foxproCodePage = string.Empty; + foxproIncludeDeleted = false; + } + StateHasChanged(); } @@ -1531,6 +1651,36 @@ else } } + private string GetFoxproConnectionStringPreview() + { + if (currentDatabaseCredential.DatabaseType != DatabaseType.Foxpro) + return string.Empty; + + try + { + currentDatabaseCredential.AdditionalParameters ??= new Dictionary(); + + if (!string.IsNullOrEmpty(foxproCodePage)) + currentDatabaseCredential.AdditionalParameters["Encoding"] = foxproCodePage; + else + currentDatabaseCredential.AdditionalParameters.Remove("Encoding"); + + if (foxproIncludeDeleted) + currentDatabaseCredential.AdditionalParameters["IncludeDeleted"] = "true"; + else + currentDatabaseCredential.AdditionalParameters.Remove("IncludeDeleted"); + + if (string.IsNullOrWhiteSpace(currentDatabaseCredential.DatabaseName)) + return "(inserire il percorso al file .dbc o alla cartella di .dbf)"; + + return ConnectionStringBuilder.BuildConnectionString(currentDatabaseCredential); + } + catch (Exception ex) + { + return $"Errore nella generazione: {ex.Message}"; + } + } + #endregion #endregion diff --git a/Data_Coupler/Services/DataConnectionFactory.cs b/Data_Coupler/Services/DataConnectionFactory.cs index 4926405..c91f68e 100644 --- a/Data_Coupler/Services/DataConnectionFactory.cs +++ b/Data_Coupler/Services/DataConnectionFactory.cs @@ -91,6 +91,14 @@ namespace Data_Coupler.Services return new DataConnection.DB.OleDbDatabaseManager(connectionString); } + // Per Visual FoxPro, usa FoxProDatabaseManager (lettura managed di .dbf/.fpt/.dbc, 64-bit) + if (credential.DatabaseType == DatabaseType.Foxpro) + { + var connectionString = CredentialManager.Models.ConnectionStringBuilder.BuildConnectionString(credential); + _logger.LogInformation("Creando FoxProDatabaseManager (sola lettura) per {CredentialName}", credentialName); + return new DataConnection.DB.FoxProDatabaseManager(connectionString); + } + // Per altri database, usa EFCoreDatabaseManager var dbManagerOptions = await _credentialService.GetDbManagerOptionsAsync(credential.Name); return new EFCoreDatabaseManager(dbManagerOptions); diff --git a/FOXPRO_CONNECTION.md b/FOXPRO_CONNECTION.md new file mode 100644 index 0000000..3323866 --- /dev/null +++ b/FOXPRO_CONNECTION.md @@ -0,0 +1,106 @@ +# Connessione a database Visual FoxPro + +Data-Coupler supporta i database **Visual FoxPro / dBase** come **sorgente dati** (sola lettura) +tramite un lettore **completamente managed**: legge direttamente i file `.dbf` (tabelle), +`.fpt` (campi memo) e `.dbc` (database container) **senza installare alcun provider OLE DB/ODBC** +e funzionando a **64-bit**. + +> ℹ️ **Perché managed e non OLE DB?** Il provider `VFPOLEDB.1` di Microsoft è **solo a 32-bit**, +> deprecato e va installato a parte. Il lettore managed non ha questi vincoli e funziona ovunque +> giri l'applicazione (anche in container Linux/Windows a 64-bit). + +--- + +## 1. Come connettersi (esempio) + +La "connessione" FoxPro è semplicemente un **percorso**. Non serve host, utente o password. + +### Passo passo nell'interfaccia + +1. Vai su **Credenziali** → **Aggiungi credenziale database**. +2. **Nome**: un nome a piacere (es. `Gestionale FoxPro`). +3. **Tipo Database**: seleziona **Visual FoxPro (.dbc / .dbf)**. +4. **Percorso Database FoxPro**: inserisci uno dei due: + + | Caso | Cosa inserire | Esempio | + |---|---|---| + | **Database container** | percorso completo al file `.dbc` | `C:\Users\aless\Desktop\data\Data.dbc` | + | **Tabelle libere** (free tables) | percorso della **cartella** con i `.dbf` | `C:\Users\aless\Desktop\data` | + +5. (Opzionale) **Code Page**: lascia `1252` per dati italiani/europei occidentali. +6. (Opzionale) **Record cancellati**: lascia deselezionato per escludere i record marcati come cancellati. +7. Premi **Test connessione**: deve mostrare il numero di tabelle rilevate. +8. **Salva**. + +A questo punto la credenziale è utilizzabile come **sorgente** nella pagina **Data Coupler**: +selezionala, attendi la discovery delle tabelle, scegli la tabella e procedi con il mapping. + +--- + +## 2. Formato della connection string + +Internamente la credenziale viene tradotta in una stringa interpretata dal lettore FoxPro. +Sono accettate tutte queste forme (utile se incolli un percorso o una stringa esistente): + +``` +C:\Users\aless\Desktop\data\Data.dbc +C:\Users\aless\Desktop\data +Data Source=C:\Users\aless\Desktop\data\Data.dbc;CodePage=1252;IncludeDeleted=false +Provider=vfpoledb;Data Source=C:\Users\aless\Desktop\data\Data.dbc;Collating Sequence=machine; +``` + +> Le stringhe stile **OLE DB** (`Provider=vfpoledb;Data Source=...`) sono accettate per comodità: +> viene estratto automaticamente il valore di `Data Source`. `Provider` e `Collating Sequence` +> vengono ignorati (il lettore è managed). + +Opzioni riconosciute: + +| Chiave | Significato | Default | +|---|---|---| +| `Data Source` | percorso al `.dbc` o alla cartella di `.dbf` | — (obbligatorio) | +| `CodePage` / `Encoding` | code page per i campi carattere/memo | auto-rilevata dall'header, fallback **1252** | +| `IncludeDeleted` | includere i record marcati come cancellati | `false` | + +--- + +## 3. Tipi di dato supportati + +| Tipo VFP | Mappato come | +|---|---| +| Character (`C`) | testo | +| Varchar (`V`) | testo | +| Memo (`M`, file `.fpt`) | testo | +| Numeric (`N`) | decimale | +| Float (`F`) | decimale | +| Double (`B`) | double | +| Integer (`I`) | intero | +| Currency (`Y`) | decimale | +| Date (`D`) | data | +| DateTime (`T`) | data/ora | +| Logical (`L`) | booleano | + +I file memo (`.fpt`) e gli indici (`.cdx`) vengono individuati **automaticamente** accanto al `.dbf`. + +--- + +## 4. Note e limitazioni + +- **Sola lettura**: FoxPro è utilizzabile solo come **sorgente**. La scrittura sui `.dbf` non è + supportata (eviterebbe il rischio di corrompere indici `.cdx` e memo `.fpt` di gestionali legacy in uso). +- **Elenco tabelle**: con un `.dbc` viene letto il catalogo del container (solo le tabelle effettivamente + presenti come file `.dbf`); con una cartella vengono elencati tutti i `.dbf` presenti. +- **Query**: la sorgente lavora in modalità "tabella" (estrazione completa della tabella selezionata). + Le eventuali query custom supportano solo `SELECT [TOP n] * FROM ` (niente JOIN/WHERE evoluti). +- **Performance**: la lettura è in **streaming**; anche tabelle da centinaia di MB vengono elaborate + con un uso di memoria contenuto. + +--- + +## 5. Risoluzione problemi + +| Sintomo | Causa probabile | Soluzione | +|---|---|---| +| "Cartella del database FoxPro non trovata" | percorso errato | verifica il percorso del `.dbc` o della cartella | +| "nessuna tabella .dbf trovata" | cartella senza `.dbf` | indica la cartella giusta o il file `.dbc` | +| Caratteri accentati errati | code page diversa | imposta la **Code Page** corretta (es. 1250, 65001) | +| Una tabella non compare | `.dbf` referenziato nel `.dbc` ma file assente | ripristina il file `.dbf` mancante |