e70abcdcb1
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>
423 lines
16 KiB
C#
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; }
|
|
}
|
|
}
|