Files
Data-Coupler/DataConnection/DB/FoxPro/FoxProReader.cs
T
Alessio e70abcdcb1 [Feature] Supporto Visual FoxPro / dBase come sorgente dati (managed, sola lettura)
Aggiunge un connettore completamente managed per database Visual FoxPro e dBase,
utilizzabile come sorgente dati in tutti i flussi dell'applicazione (discovery manuale,
esecuzione schedulata, hash/change-detection, backup/restore).

## Motivazione

Il provider OLE DB ufficiale (VFPOLEDB.1) è solo a 32-bit, deprecato e richiede
installazione separata. Il connettore managed usa DbfDataReader 1.0.0 (MIT), legge
direttamente i file .dbf/.fpt/.dbc a 64-bit senza dipendenze esterne e con streaming
efficiente (tabelle da centinaia di MB con uso di memoria contenuto).

## Nuovi file

- DataConnection/DB/FoxPro/FoxProConnectionInfo.cs
  Classe sealed con le informazioni di connessione risolte (cartella, percorso .dbc,
  encoding, flag IncludeDeleted). Proprietà di convenienza IsContainer e DisplayName.

- DataConnection/DB/FoxPro/FoxProReader.cs
  Helper statico che registra CodePagesEncodingProvider, risolve la connection string
  (path diretto, stile OLE DB con Provider=vfpoledb, chiave=valore), verifica l'accesso
  al filesystem, legge il catalogo .dbc (il file .dbc è esso stesso un DBF; filtro su
  OBJECTTYPE='Table' + cross-check esistenza fisica .dbf), enumera le tabelle free,
  mappa i tipi VFP (Character, Memo, Numeric, Float, Double, Integer, Currency, Date,
  DateTime, Logical, General) in descrizioni leggibili, esegue streaming dei record via
  DbfDataReader, auto-rileva l'encoding dall'header DBF (language driver byte) con
  fallback a code page 1252, e analizza query SELECT [TOP n] * FROM tabella.

- DataConnection/DB/FoxProDatabaseManager.cs
  Implementa IDatabaseManager. Lettura: TestConnectionAsync, GetTableNamesAsync,
  GetTableSchemaAsync, GetDatabaseSchemaAsync, GetAllRecordsAsync, ExecuteRawQueryAsync,
  GetPrimaryKeyFieldAsync (ritorna null, selezione manuale chiave), GetAvailableDatabasesAsync,
  ChangeDatabaseAsync (no-op per sorgenti file-based).
  Scrittura: tutti i metodi di modifica (Insert/Update/Delete/Upsert/BulkInsert/
  ExecuteCommand/ExecuteNonQuery) lanciano NotSupportedException con messaggio esplicito.

- DataConnection/DB/EF/SchemaProviders/FoxProSchemaProvider.cs
  Implementa IDatabaseSchemaProvider delegando a FoxProReader. Gestione errori per tabella
  in GetDatabaseSchemaAsync (una tabella non apribile non blocca la discovery).

- FOXPRO_CONNECTION.md
  Guida utente: passo-passo per creare la credenziale, formato connection string,
  tabella tipi VFP supportati, note su sola lettura e limitazioni query, tabella
  risoluzione problemi (caratteri accentati, tabelle mancanti, ecc.).

## File modificati

- DataConnection/DataConnection.csproj
  Aggiunto DbfDataReader 1.0.0 (porta transitivamente System.Text.Encoding.CodePages
  >= 10.0.3; rimossa dipendenza esplicita alla versione 9.0.3 che causava NU1605).

- DataConnection/DB/Enums/DatabaseType.cs
  Aggiunto valore Foxpro in fondo all'enum (preserva valori già persistiti).

- CredentialManager/Models/CredentialModels.cs
  Aggiunto DatabaseType.Foxpro all'enum e metodo BuildFoxproConnectionString che genera
  "Data Source=percorso[;CodePage=n][;IncludeDeleted=true]".

- DataConnection/CredentialManagement/Models/CredentialExtensions.cs
  Mappatura bidirezionale Foxpro tra enum CredentialManager e DataConnection.

- DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs
  Aggiunto TestFoxproConnection: verifica percorso accessibile, legge nomi tabelle,
  restituisce messaggio con conteggio tabelle, encoding rilevato e nota sola lettura.

- DataConnection/DB/EF/DatabaseSchemaProviderFactory.cs
  Caso Foxpro => new FoxProSchemaProvider().

- Data_Coupler/Services/DataConnectionFactory.cs
  Branch Foxpro => new FoxProDatabaseManager(connectionString).

- Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs
  Aggiunto IsFoxproConnection() e caso Foxpro in CreateLimitedQuery
  (SELECT TOP n * FROM (query) AS subquery).

- CredentialManager/Integration/DataConnectionHelper.cs
  ValidateDatabaseCredential: introdotto flag isFileBased (Sqlite || Foxpro) per
  esonerare host/utente/password/porta; messaggio di errore specifico per il campo
  percorso FoxPro.

- Data_Coupler/Pages/CredentialManagement.razor
  Aggiunta opzione "Visual FoxPro (.dbc / .dbf)" nel selettore tipo database.
  Sezione di configurazione dedicata: banner sola lettura, campo percorso con esempi
  (.dbc e cartella), dropdown code page (Auto/1252/1250/1251/850/437/65001), checkbox
  record cancellati, pannello tipi supportati, anteprima connection string.
  Fix validazione form test-connessione: branch Foxpro che verifica solo DatabaseName
  (non Host/Username/Password), eliminando il falso errore "Il campo Host è obbligatorio".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 19:34:21 +02:00

423 lines
16 KiB
C#

using System.Text;
using System.Text.RegularExpressions;
using DbfDataReader;
using DataConnection.Interfaces;
namespace DataConnection.DB.FoxPro;
/// <summary>
/// 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 <c>.dbf</c> (tabelle), <c>.fpt</c> (campi memo) e, quando
/// la sorgente è un container <c>.dbc</c>, ne legge il catalogo per ricavare l'elenco
/// ufficiale delle tabelle. La libreria sottostante è <c>DbfDataReader</c> (MIT).
///
/// Sola lettura: il coupler usa FoxPro come sorgente dati.
/// </summary>
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);
}
/// <summary>Code page di default (Windows-1252, ANSI Europa occidentale).</summary>
public const int DefaultCodePage = 1252;
// Mappa byte "language driver" dell'header DBF -> code page più comuni.
private static readonly Dictionary<byte, int> 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
};
/// <summary>
/// Interpreta la "connection string" FoxPro (un percorso, eventualmente con opzioni)
/// e risolve cartella, eventuale <c>.dbc</c>, encoding e gestione record cancellati.
///
/// Formati accettati:
/// <list type="bullet">
/// <item>percorso nudo a un file <c>.dbc</c>: <c>C:\dati\Data.dbc</c></item>
/// <item>percorso nudo a una cartella di <c>.dbf</c>: <c>C:\dati</c></item>
/// <item>stringa con chiavi: <c>Data Source=C:\dati\Data.dbc;CodePage=1252;IncludeDeleted=false</c></item>
/// <item>stringa stile OLE DB VFP: <c>Provider=vfpoledb;Data Source=C:\dati\Data.dbc;...</c> (Data Source viene estratto)</item>
/// </list>
/// </summary>
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
};
}
/// <summary>Verifica che la sorgente sia raggiungibile (cartella esistente, eventuale .dbc presente).</summary>
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}");
}
/// <summary>
/// Elenco delle tabelle: dal catalogo del <c>.dbc</c> (OBJECTTYPE='Table') se container,
/// altrimenti enumerando i file <c>.dbf</c> nella cartella. Vengono restituite solo le
/// tabelle effettivamente apribili (file <c>.dbf</c> presente).
/// </summary>
public static List<string> GetTableNames(FoxProConnectionInfo info)
{
EnsureReachable(info);
var names = new List<string>();
var seen = new HashSet<string>(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;
}
/// <summary>Schema (colonne) di una singola tabella.</summary>
public static List<DbColumnInfo> 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<DbColumnInfo>();
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;
}
/// <summary>
/// Legge i record di una tabella come dizionari nome-colonna -> valore.
/// I valori null sono rappresentati con <see cref="DBNull.Value"/> (coerente con gli altri manager).
/// Streaming: non carica l'intera tabella in memoria.
/// </summary>
public static IEnumerable<Dictionary<string, object>> 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<string, object>(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;
}
}
/// <summary>Numero di record (header) di una tabella, senza scorrere i dati.</summary>
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;
}
/// <summary>
/// Estrae il nome tabella (e un eventuale <c>TOP n</c>) da una semplice query SELECT.
/// Gestisce anche le query "wrappate" generate da CreateLimitedQuery
/// (<c>SELECT TOP n * FROM (SELECT ... FROM tabella) AS subquery</c>): viene preso
/// l'ultimo identificatore dopo FROM, cioè la tabella reale più interna.
/// </summary>
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);
}
/// <summary>Risolve il path del file .dbf per una tabella, gestendo il case-insensitive di Windows.</summary>
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<string> 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; }
}
}