diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index ba6f87f..60c6976 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -31,6 +31,47 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Generate version.json with MinVer + run: | + # Fetch all tags for MinVer to work correctly + git fetch --tags --force + + # Build project to trigger MinVer (calcola versione automaticamente) + cd Data_Coupler + dotnet build -c Release /p:ContinuousIntegrationBuild=true + + # Extract version calculated by MinVer from build output + VERSION=$(dotnet msbuild -getProperty:Version -p:ContinuousIntegrationBuild=true 2>/dev/null | tail -1) + + # Fallback if MinVer fails (no tags) + if [ -z "$VERSION" ] || [ "$VERSION" = "0.0.0-alpha.0" ]; then + echo "Warning: No git tags found. MinVer returned default. Using fallback." + VERSION="2.1.0-alpha.0.$(git rev-list --count HEAD)" + fi + + echo "MinVer calculated version: $VERSION" + + # Create version.json + cat > wwwroot/version.json <$null | Select-Object -Last 1 + + # Fallback if MinVer fails (no tags) + if ([string]::IsNullOrWhiteSpace($VERSION) -or $VERSION -eq "0.0.0-alpha.0") { + Write-Host "Warning: No git tags found. MinVer returned default. Using fallback." + $commitCount = git rev-list --count HEAD + $VERSION = "2.1.0-alpha.0.$commitCount" + } + + Write-Host "MinVer calculated version: $VERSION" + + $COMMIT_SHA = "${{ github.sha }}" + $SHORT_SHA = $COMMIT_SHA.Substring(0, 7) + $BRANCH = "${{ github.ref_name }}" + $BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC") + + # Create version.json + $versionJson = @{ + version = $VERSION + commitSha = $SHORT_SHA + branch = $BRANCH + buildDate = $BUILD_DATE + buildEnvironment = "Gitea Actions" + } | ConvertTo-Json + + $versionJson | Out-File -FilePath "wwwroot\version.json" -Encoding UTF8 + + Write-Host "Generated version.json:" + Get-Content "wwwroot\version.json" + cd .. + shell: pwsh + - name: Debug - Verify files run: | echo Working directory: @@ -197,18 +306,15 @@ jobs: run: | IMAGE_LOWER=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') docker buildx imagetools create -t ${IMAGE_LOWER}:latest \ - ${IMAGE_LOWER}:latest \ + ${IMAGE_LOWER}:latest-linux \ ${IMAGE_LOWER}:latest-windows - name: Create and push manifest for development branch if: github.ref == 'refs/heads/development' run: | IMAGE_LOWER=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') - docker buildx imagetools create -t ${IMAGE_LOWER}:latest \ - ${IMAGE_LOWER}:latest \ - ${IMAGE_LOWER}:latest-windows docker buildx imagetools create -t ${IMAGE_LOWER}:development-latest \ - ${IMAGE_LOWER}:development-latest \ + ${IMAGE_LOWER}:development-latest-linux \ ${IMAGE_LOWER}:development-latest-windows - name: Create and push manifest for dev branch @@ -216,7 +322,7 @@ jobs: run: | IMAGE_LOWER=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') docker buildx imagetools create -t ${IMAGE_LOWER}:dev-latest \ - ${IMAGE_LOWER}:dev-latest \ + ${IMAGE_LOWER}:dev-latest-linux \ ${IMAGE_LOWER}:dev-latest-windows - name: Create and push manifest for staging branch @@ -224,5 +330,5 @@ jobs: run: | IMAGE_LOWER=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') docker buildx imagetools create -t ${IMAGE_LOWER}:staging-latest \ - ${IMAGE_LOWER}:staging-latest \ + ${IMAGE_LOWER}:staging-latest-linux \ ${IMAGE_LOWER}:staging-latest-windows diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 52b98a0..d2de40c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -107,8 +107,11 @@ - **Parallel Processing**: Elaborazione parallela batch multipli - **Performance**: 10-25x più veloce per grandi dataset - **Riduzione API Calls**: 60-90% in meno chiamate +- **Batch Describe Metadata**: `BatchDescribeSObjectsAsync` raggruppa le describe degli SObject in chunk da 25 (N chiamate singole → ⌈N/25⌉ richieste batch); per 200 SObject: da 201 a 9 chiamate +- **Discovery Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` eseguite in parallelo; UI interattiva dopo le summaries, dettagli completano in background #### Metodi Batch Implementati: +- `BatchDescribeSObjectsAsync`: Describe batch SObject tramite Composite API (max 25 per request) — discovery metadati ottimizzata - `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL - `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi - `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query) @@ -117,6 +120,10 @@ - `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia - `ExtractRecentlyModifiedAsync`: Sincronizzazione incrementale +#### Correzioni Scheduler (Febbraio 2026): +- **ExternalIdRelationshipsJson / DefaultValuesJson preservati**: Fix ai blocchi di update profilo esistente in `DataCoupler.razor.cs` — i campi JSON venivano ignorati nella copia e quindi azzerati; ora entrambi i path (riattivazione + sovrascrittura) li propagano correttamente +- **Esclusione campi External ID dal mapping normale**: In `ScheduledProfileExecutionService.TransformRecordForRest`, i campi sorgente usati nelle External ID Relationships vengono ora esclusi dal loop di field mapping standard (comportamento allineato alla UI manuale) + #### File Chiave: - `Data_Coupler/Pages/DataCoupler.razor.cs` - `DataConnection/REST/Implementations/SalesforceServiceClient.cs` @@ -146,6 +153,8 @@ - **Pausa/Riprendi**: Controllo dinamico schedulazioni - **Override Database**: Possibilità di sovrascrivere sorgente/destinazione - **Deletion Sync Configurabile**: Opzione per abilitare sincronizzazione eliminazioni (disabilitata di default) +- **Supporto File CSV/Excel**: Schedulazione completa per profili con file come sorgente +- **Validazione File**: Verifica esistenza e leggibilità file prima dell'esecuzione #### File Chiave: - `CredentialManager/Models/ProfileSchedule.cs` @@ -249,7 +258,38 @@ - **Dark/Light Mode**: Temi personalizzabili - **Mobile Responsive**: Ottimizzato per dispositivi mobili -### 10. Health Checks e Monitoraggio +### 10. Gestione File per Schedulazioni + +#### Caratteristiche: +- **Doppia Modalità Caricamento**: Browser (preview) + percorso manuale (schedulazione) +- **Validazione Percorsi**: Verifica esistenza e permessi lettura file +- **Supporto CSV**: Rilevamento automatico separatori, gestione quote e escape +- **Supporto Excel**: Formati .xlsx e .xls, lettura automatica primo foglio +- **Schedulazione Completa**: File CSV/Excel utilizzabili in schedulazioni automatiche +- **Logging Dettagliato**: Tracciamento lettura file e parsing + +#### Modalità Operative: + +**Caricamento Browser (Preview)**: +- Carica file tramite InputFile component +- Processato in memoria per anteprima +- Non salvato sul server +- Utilizzato solo per configurazione mapping + +**Percorso Manuale (Schedulazione)**: +- Campo "Percorso File sul Server" obbligatorio +- Validazione esistenza e leggibilità +- Percorso salvato in `SourceFilePath` del profilo +- Utilizzato per esecuzioni schedulate +- Esempi: `C:\Data\products.csv`, `/data/customers.xlsx` + +#### File Chiave: +- `Data_Coupler/Pages/DataCoupler.razor` (UI caricamento file) +- `Data_Coupler/Pages/DataCoupler.razor.cs` (validazione file) +- `Data_Coupler/Services/ScheduledProfileExecutionService.cs` (lettura file schedulazioni) +- `CSV_SCHEDULING_IMPLEMENTATION.md` (documentazione completa) + +### 11. Health Checks e Monitoraggio #### Caratteristiche: - **Health Checks**: Endpoint per monitoraggio stato applicazione @@ -262,6 +302,35 @@ - `Data_Coupler/HealthChecks/DatabaseHealthCheck.cs` - `Data_Coupler/HealthChecks/BackgroundServiceHealthCheck.cs` +### 12. Sistema di Versioning Automatizzato + +#### Caratteristiche: +- **Versioning Automatico**: Generazione automatica della versione tramite Gitea Actions +- **Display UI**: Versione visibile nel NavMenu dell'applicazione +- **Semantic Versioning**: Segue il pattern MAJOR.MINOR.PATCH +- **Metadati Build**: Commit SHA, branch, data build, ambiente +- **Fallback Intelligente**: Versione di default se file non disponibile + +#### Componenti: +- **version.json**: File generato automaticamente durante il build +- **VersionInfo**: Modello dati per informazioni versione +- **VersionService**: Servizio singleton per gestione versione +- **NavMenu Integration**: Display "Data_Coupler v2.1.0" nel navbar + +#### Workflow: +1. Git Push → Gitea Actions triggered +2. Workflow genera `version.json` con versione da csproj +3. Docker build include il file version.json +4. VersionService carica al startup +5. NavMenu mostra versione nell'interfaccia + +#### File Chiave: +- `Data_Coupler/Models/VersionInfo.cs` +- `Data_Coupler/Services/VersionService.cs` +- `Data_Coupler/wwwroot/version.json` +- `.gitea/workflows/docker-build.yml` +- `VERSIONING_SYSTEM.md` (documentazione completa) + ## 🔐 Sicurezza ### Gestione Credenziali: @@ -408,6 +477,8 @@ - **SALESFORCE_BATCH_EXTRACTION_IMPROVEMENTS.md**: Batch extraction Salesforce - **PRE_DISCOVERY_SYSTEM.md**: Sistema pre-discovery associazioni - **DELETION_SYNC_IMPLEMENTATION.md**: Sincronizzazione eliminazioni +- **CSV_SCHEDULING_IMPLEMENTATION.md**: Schedulazione file CSV/Excel +- **VERSIONING_SYSTEM.md**: Sistema di versioning automatizzato (NUOVO) - **DOCKER_DEPLOYMENT.md**: Guida deployment Docker - **WINDOWS_SERVICE_DEPLOYMENT.md**: Deploy come Windows Service - **.gitea/workflows/README.md**: Configurazione Gitea Actions @@ -443,7 +514,8 @@ ## 🚀 Roadmap Futura ### Feature in Pianificazione: -- [ ] Supporto file Excel/CSV avanzato +- [x] Supporto file Excel/CSV avanzato (Completato - Gennaio 2026) +- [x] Sistema di versioning automatizzato (Completato - Febbraio 2026) - [ ] Sistema di notifiche (email, webhook) - [ ] Dashboard analytics avanzato - [ ] Multi-tenant support @@ -451,6 +523,8 @@ - [ ] Plugin system per connectors custom - [ ] Machine learning per mapping suggeriti - [ ] Real-time data sync +- [ ] Lettura fogli Excel multipli +- [ ] Supporto file remoti (HTTP, FTP, Azure Blob) ### Miglioramenti Tecnici: - [ ] Migrazione a .NET 10 (quando disponibile) @@ -461,8 +535,8 @@ --- -**Versione**: 2.0 -**Ultimo Aggiornamento**: 22 Gennaio 2026 +**Versione**: 2.2 +**Ultimo Aggiornamento**: 20 Febbraio 2026 **Framework**: .NET 9.0 **Sviluppatore**: Alessio Dalsanto **Repository**: https://github.com/AlessioDalsi/Data-Coupler diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 0fbbbe6..d6c2cd9 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -48,11 +48,13 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - # Tag based on branch + # Tag based on branch - latest only for main type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/development' }} + # Development branch - no latest tag type=raw,value=development-latest,enable=${{ github.ref == 'refs/heads/development' }} + # Dev branch type=raw,value=dev-latest,enable=${{ github.ref == 'refs/heads/dev' }} + # Staging branch type=raw,value=staging-latest,enable=${{ github.ref == 'refs/heads/staging' }} # Tag with commit sha type=sha,prefix={{branch}}-,format=short @@ -173,9 +175,6 @@ jobs: if: github.ref == 'refs/heads/development' run: | IMAGE_LOWER=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') - docker buildx imagetools create -t ${IMAGE_LOWER}:latest \ - ${IMAGE_LOWER}:latest \ - ${IMAGE_LOWER}:latest-windows docker buildx imagetools create -t ${IMAGE_LOWER}:development-latest \ ${IMAGE_LOWER}:development-latest \ ${IMAGE_LOWER}:development-latest-windows diff --git a/.gitignore b/.gitignore index 676f4a9..fdace1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Created by https://www.toptal.com/developers/gitignore/api/csharp,visualstudiocode,visualstudio # Edit at https://www.toptal.com/developers/gitignore?templates=csharp,visualstudiocode,visualstudio +# Data-Coupler specific +# Version file generato automaticamente durante il build +Data_Coupler/wwwroot/version.json + ### Csharp ### ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. diff --git a/AGENTS.md b/AGENTS.md index 2712db0..18f88ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,30 @@ - **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati - **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza +## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Optimizations (Febbraio 2026)** + +### Salesforce Batch Describe via Composite API +**Data Aggiornamento**: Febbraio 2026 + +La discovery dei metadati Salesforce è stata ottimizzata tramite la Composite Batch API: + +#### **`BatchDescribeSObjectsAsync`** (nuovo metodo privato in `SalesforceServiceClient`) +- Raggruppa i nomi degli SObject in chunk da 25 +- Ogni chunk viene inviato come singola `POST /services/data/vXX.0/composite/batch` +- I risultati vengono processati in parallelo via `Task.WhenAll` +- **Risparmio concreto**: per 200 SObject, da 201 chiamate API a sole 9 + +#### **Discovery Parallela in `RESTMethod.cs`** +- `DiscoverEntitySummariesAsync` (rapida, 1 chiamata) e `DiscoverEntitiesAsync` (batch) partono in parallelo +- La lista entità diventa interattiva dopo ~0.3 s; i dettagli completano in background +- `StateHasChanged()` chiamato dopo le summaries per aggiornare subito la UI + +#### **Fix Scheduler: External ID Relationships e Default Values** +- **Bug 1** (`DataCoupler.razor.cs`): in entrambi i blocchi di update profilo esistente (riattivazione profilo inattivo + sovrascrittura profilo attivo), i campi `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano omessi nella copia → cancellati silenziosamente ad ogni re-salvataggio +- **Bug 2** (`ScheduledProfileExecutionService.cs`): `TransformRecordForRest` non escludeva i campi sorgente usati nelle External ID Relationships dal loop di mapping normale, causando dati duplicati nell'entità destinazione (stessa logica già presente nella UI manuale, ora allineata allo scheduler) + +--- + ## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Batch Extraction** ### Miglioramenti Significativi alle Performance REST @@ -1151,7 +1175,7 @@ builder.Services.AddScoped>> GetAllRecordsFromSourceAsync( + DataCouplerProfile profile, IDatabaseManager? databaseManager) +{ + if (profile.SourceType.ToLower() == "file") + { + return await GetAllRecordsFromFileAsync(profile); + } + // ... altri tipi +} +``` + +**Caratteristiche**: +- Supporto completo per CSV con separatori multipli +- Gestione corretta di campi con virgolette e caratteri speciali +- Parsing robusto con logging dettagliato +- Compatibilità con file Excel legacy (.xls) e moderni (.xlsx) + +--- + +### 3. Abilitazione Profili File nella Schedulazione +**File**: `Data_Coupler/Pages/Scheduling.razor` + +**Modifica**: Rimosso il filtro `Where(p => p.SourceType != "file")` che escludeva i profili file dalla lista di schedulazione. + +**Prima**: +```csharp +@foreach (var profile in availableProfiles.Where(p => p.SourceType != "file")) +{ + +} +``` + +**Dopo**: +```csharp +@foreach (var profile in availableProfiles) +{ + +} +``` + +**Miglioramenti UI**: +- Etichetta `(File)` per identificare profili con file +- Messaggio informativo aggiornato: + > ℹ️ I profili con file CSV/Excel come sorgente sono ora supportati per le schedulazioni. + > Il file specificato nel profilo verrà letto ad ogni esecuzione. + +--- + +### 4. Gestione Cancellazioni per File CSV +**Implementazione**: La gestione delle cancellazioni opzionali funziona automaticamente anche per i profili file grazie all'architettura esistente. + +**Flusso**: +1. I record vengono letti dal file CSV/Excel +2. Vengono trasformati in `Dictionary` come i record database +3. Il sistema di associazioni (`KeyAssociationService`) traccia i record +4. Se abilitato, il `DeletionSyncService` sincronizza le eliminazioni + +**Compatibilità**: +- ✅ Supporto completo per `EnableDeletionSync` flag +- ✅ Tracking chiavi sorgente (`SourceKeyField`) +- ✅ Sistema di associazioni record +- ✅ Pre-discovery di record esistenti + +--- + +## Gestione File per Schedulazioni + +### Due Modalità di Caricamento + +#### 1. **Caricamento Browser** (per Preview) +- **Scopo**: Configurare mapping e vedere anteprima dati +- **Funzionamento**: + - File caricato tramite browser (InputFile component) + - Processato in memoria + - **Non salvato sul server** +- **Uso**: Solo per configurazione iniziale del profilo + +#### 2. **Percorso Manuale** (per Schedulazione) ⭐ +- **Scopo**: Specificare posizione file per schedulazioni +- **Funzionamento**: + - Utente inserisce percorso completo (es: `C:\Data\products.csv`) + - Sistema valida esistenza e leggibilità + - Percorso salvato nel profilo +- **Uso**: **Obbligatorio** per profili che devono essere schedulati + +### Esempi di Percorsi Validi + +**Windows**: +``` +C:\Data\products.csv +\\server\share\customers.xlsx +D:\ImportFiles\orders.csv +``` + +**Linux/Container**: +``` +/data/products.csv +/mnt/share/customers.xlsx +/app/import/orders.csv +``` + +### Workflow Completo + +1. **Configurazione Iniziale**: + - Carica file da browser per preview + - Configura mapping campi vedendo i dati reali + - **Importante**: Questo file è solo temporaneo + +2. **Preparazione Schedulazione**: + - Posiziona il file nella location definitiva sul server + - Inserisci il percorso completo nel campo "Percorso File sul Server" + - Clicca "Valida e Carica" per verificare + - Salva il profilo + +3. **Esecuzione Schedulata**: + - Il sistema legge il file dal percorso salvato + - Il file deve esistere e essere accessibile + - Aggiornamenti al file vengono letti automaticamente + +### Considerazioni Importanti + +**Sicurezza**: +- Il file deve essere accessibile dal processo dell'applicazione +- Verificare permessi di lettura sulla directory +- Per ambienti multi-utente, considerare ACL appropriati + +**Percorsi Relativi vs Assoluti**: +- **Consigliato**: Percorsi assoluti per chiarezza +- Se si usano percorsi relativi, sono relativi alla working directory dell'applicazione + +**Aggiornamento File**: +- Il sistema legge sempre il file corrente dal percorso +- Per aggiornare i dati, basta sovrascrivere il file nella stessa posizione +- Non serve modificare il profilo se il percorso rimane invariato + +**Deployment Container**: +- Montare volumi per directory contenenti i file +- Esempio docker-compose: + ```yaml + volumes: + - /host/data:/data # Monta directory host in /data nel container + ``` +- Nel profilo usare: `/data/products.csv` + +**Best Practices**: +- ✅ Usare una directory dedicata per file di import (es: `/data/imports/`) +- ✅ Nominare i file in modo descrittivo +- ✅ Implementare rotazione/backup dei file +- ✅ Monitorare spazio disco +- ❌ Non usare directory temporanee che vengono pulite +- ❌ Non usare percorsi di rete senza verifica connessione + +--- + +## Funzionamento Completo + +### Creazione Profilo con File CSV +1. L'utente seleziona "File" come tipo sorgente +2. **Opzione A - Caricamento Browser (per preview)**: + - Carica un file CSV/Excel tramite browser + - Il file viene processato in memoria per preview + - Permette di configurare il mapping vedendo i dati reali + - **Non viene salvato sul server** - solo per anteprima + +3. **Opzione B - Percorso Manuale (richiesto per schedulazione)**: + - Inserisce il percorso completo del file sul server (es: `C:\Data\products.csv`) + - Clicca "Valida e Carica" per: + - Verificare che il file esista + - Verificare che sia leggibile + - Caricare preview per configurare mapping + - Il percorso viene salvato nel profilo + +4. L'utente configura il mapping campi +5. **Salvataggio**: Il sistema valida che il file sia accessibile e leggibile +6. Il **percorso completo originale** del file viene salvato in `SourceFilePath` + +**Nota Importante**: Per le schedulazioni è **necessario** specificare il percorso file manualmente. Il file deve essere accessibile dal server nella posizione specificata. + +### Schedulazione Profilo File +1. L'utente crea una nuova schedulazione +2. Seleziona un profilo con `SourceType = "file"` +3. Configura la frequenza (giornaliera, settimanale, intervallo, ecc.) +4. Abilita opzionalmente `EnableDeletionSync` per sincronizzare eliminazioni + +### Esecuzione Schedulata +1. Il background service avvia l'esecuzione alla schedulazione prevista +2. `ScheduledProfileExecutionService.ExecuteProfileAsync` viene chiamato +3. Il servizio legge il file dal percorso salvato usando `GetAllRecordsFromFileAsync` +4. I record vengono trasformati e inviati alla destinazione REST +5. Il sistema di associazioni traccia i record per evitare duplicati +6. Se configurato, vengono sincronizzate le eliminazioni + +--- + +## Sicurezza e Validazioni + +### Validazioni Implementate: +- ✅ Verifica esistenza file prima del salvataggio profilo +- ✅ Verifica permessi di lettura file +- ✅ Gestione eccezioni durante la lettura file +- ✅ Logging dettagliato per troubleshooting +- ✅ Validazione formato file (CSV, XLSX, XLS) + +### Considerazioni di Sicurezza: +- Il file deve essere accessibile dal processo dell'applicazione +- Percorsi assoluti sono salvati nel database +- Per ambienti containerizzati, montare volumi con i file +- Permessi filesystem devono consentire lettura + +--- + +## Formati File Supportati + +### CSV +- **Separatori**: `,` (virgola), `;` (punto e virgola), `\t` (tab), `|` (pipe) +- **Rilevamento automatico**: Sì +- **Gestione quote**: Supporto completo per campi tra virgolette +- **Escape caratteri**: Supporto per `""` (double quote escape) +- **Dimensione massima**: 50 MB (configurabile) + +### Excel +- **Formati**: `.xlsx` (Office Open XML), `.xls` (Binary Format) +- **Fogli multipli**: Legge il primo foglio per default +- **Header**: Prima riga utilizzata come intestazione +- **Dimensione massima**: 50 MB (configurabile) + +--- + +## Esempi di Utilizzo + +### Esempio 1: Schedulazione Giornaliera CSV +``` +Profilo: "Import Prodotti da CSV" +- Sorgente: File CSV (products.csv) +- Destinazione: REST API (Salesforce) +- SourceKeyField: "ProductCode" +- UseRecordAssociations: true + +Schedulazione: "Import Prodotti Quotidiano" +- Tipo: Daily (Giornaliera) +- Ora: 08:00 +- EnableDeletionSync: false +``` + +**Comportamento**: Ogni giorno alle 08:00, il sistema legge `products.csv`, trasforma i dati secondo il mapping configurato, e li invia a Salesforce. I record esistenti vengono aggiornati, i nuovi creati. + +### Esempio 2: Sincronizzazione con Eliminazioni +``` +Profilo: "Sincronizza Clienti Excel" +- Sorgente: File Excel (customers.xlsx) +- Destinazione: REST API (SAP Business One) +- SourceKeyField: "CustomerID" +- UseRecordAssociations: true + +Schedulazione: "Sync Clienti Settimanale" +- Tipo: Weekly (Settimanale) +- Giorno: Lunedì +- Ora: 06:00 +- EnableDeletionSync: true +``` + +**Comportamento**: Ogni lunedì alle 06:00, il sistema: +1. Legge `customers.xlsx` +2. Sincronizza i clienti con SAP Business One +3. Identifica clienti eliminati dal file +4. Elimina i corrispondenti record in SAP B1 + +--- + +## Testing e Validazione + +### Test Consigliati: +1. **Test file CSV con separatori diversi** + - Comma-separated + - Semicolon-separated + - Tab-separated + +2. **Test file Excel** + - Formato .xlsx moderno + - Formato .xls legacy + - Fogli con molte colonne/righe + +3. **Test errori** + - File non esistente + - File senza permessi di lettura + - File corrotto + - File troppo grande + +4. **Test schedulazione** + - Esecuzione immediata manuale + - Esecuzione automatica schedulata + - Verifica storico esecuzioni + - Test con `EnableDeletionSync` attivo + +--- + +## Limitazioni Note + +1. **Fogli Excel**: Attualmente viene letto solo il primo foglio del file Excel +2. **Dimensione file**: Limite di 50 MB per sicurezza (configurabile in codice) +3. **Percorsi assoluti**: I file devono essere accessibili tramite percorso assoluto +4. **Encoding**: Supporto per encoding standard (UTF-8, Windows-1252) + +--- + +## Prossimi Sviluppi Potenziali + +- [ ] Supporto per lettura di fogli Excel multipli +- [ ] Configurazione dinamica del foglio Excel da leggere +- [ ] Supporto per file remoti (HTTP, FTP, Azure Blob) +- [ ] Cache intelligente per file grandi non modificati +- [ ] Validazione schema file prima dell'esecuzione +- [ ] Notifiche in caso di file non trovato durante schedulazione + +--- + +## Conclusioni + +L'implementazione della schedulazione per file CSV/Excel è ora completa e robusta. La funzionalità include: + +✅ Validazione completa del file in fase di configurazione +✅ Lettura affidabile di CSV con separatori multipli +✅ Supporto per file Excel moderni e legacy +✅ Integrazione completa con sistema di associazioni +✅ Supporto per sincronizzazione eliminazioni opzionale +✅ Logging dettagliato per troubleshooting +✅ Error handling robusto + +Gli utenti possono ora schedulare trasferimenti dati da file CSV/Excel esattamente come farebbero con sorgenti database, con le stesse funzionalità avanzate di tracking record e sincronizzazione. diff --git a/Components/ProfileSaver.razor.cs b/Components/ProfileSaver.razor.cs index 0622905..aa61983 100644 --- a/Components/ProfileSaver.razor.cs +++ b/Components/ProfileSaver.razor.cs @@ -25,6 +25,8 @@ public partial class ProfileSaver [Parameter] public string? DestinationTable { get; set; } [Parameter] public string? DestinationEndpoint { get; set; } [Parameter] public List? FieldMappings { get; set; } + [Parameter] public Dictionary? DefaultValues { get; set; } + [Parameter] public List? ExternalIdRelationships { get; set; } [Parameter] public string? SourceKeyField { get; set; } [Parameter] public bool UseRecordAssociations { get; set; } [Parameter] public EventCallback OnProfileSaved { get; set; } @@ -78,6 +80,8 @@ public partial class ProfileSaver DestinationTable = DestinationTable, DestinationEndpoint = DestinationEndpoint, FieldMappings = FieldMappings, + DefaultValues = DefaultValues, + ExternalIdRelationships = ExternalIdRelationships, SourceKeyField = SourceKeyField, UseRecordAssociations = UseRecordAssociations }; diff --git a/CredentialManager/Data/Migrations/20260202165251_AddOdbcFieldsToCredentialEntity.Designer.cs b/CredentialManager/Data/Migrations/20260202165251_AddOdbcFieldsToCredentialEntity.Designer.cs new file mode 100644 index 0000000..30757f6 --- /dev/null +++ b/CredentialManager/Data/Migrations/20260202165251_AddOdbcFieldsToCredentialEntity.Designer.cs @@ -0,0 +1,593 @@ +// +using System; +using CredentialManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CredentialManager.Data.Migrations +{ + [DbContext(typeof(CredentialDbContext))] + [Migration("20260202165251_AddOdbcFieldsToCredentialEntity")] + partial class AddOdbcFieldsToCredentialEntity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcDsnName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DatabaseType"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Credentials", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeletionAction") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("DeletionMarkField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DeletionMarkValue") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SyncDeletions") + .HasColumnType("INTEGER"); + + b.Property("UseRecordAssociations") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationCredentialId"); + + b.HasIndex("DestinationType"); + + b.HasIndex("IsActive"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SourceCredentialId"); + + b.HasIndex("SourceType"); + + b.ToTable("DataCouplerProfiles", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DeletionSynced") + .HasColumnType("INTEGER"); + + b.Property("DeletionSyncedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsSourceDeleted") + .HasColumnType("INTEGER"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("MappedDestinationField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationEntity"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyValue") + .HasDatabaseName("IX_KeyAssociations_KeyValue"); + + b.HasIndex("LastVerifiedAt"); + + b.HasIndex("RestCredentialName"); + + b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName") + .IsUnique() + .HasDatabaseName("IX_KeyAssociations_Unique"); + + b.ToTable("KeyAssociations", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EnableDeletionSync") + .HasColumnType("INTEGER"); + + b.Property("ExecutionCount") + .HasColumnType("INTEGER"); + + b.Property("IntervalUnit") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IntervalValue") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastExecutionRecordCount") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecutionTime") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledDateTime") + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileSchedules"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ProfileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecordsProcessed") + .HasColumnType("INTEGER"); + + b.Property("RecordsWithErrors") + .HasColumnType("INTEGER"); + + b.Property("ScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SourceInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggeredBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("StartTime"); + + b.HasIndex("Status"); + + b.HasIndex("TriggerType"); + + b.ToTable("ScheduleExecutionHistories", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") + .WithMany() + .HasForeignKey("DestinationCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential") + .WithMany() + .HasForeignKey("SourceCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DestinationCredential"); + + b.Navigation("SourceCredential"); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.HasOne("CredentialManager.Models.ProfileSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CredentialManager/Data/Migrations/20260202165251_AddOdbcFieldsToCredentialEntity.cs b/CredentialManager/Data/Migrations/20260202165251_AddOdbcFieldsToCredentialEntity.cs new file mode 100644 index 0000000..84d38d5 --- /dev/null +++ b/CredentialManager/Data/Migrations/20260202165251_AddOdbcFieldsToCredentialEntity.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Data.Migrations +{ + /// + public partial class AddOdbcFieldsToCredentialEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OdbcDsnName", + table: "Credentials", + type: "TEXT", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "OdbcMode", + table: "Credentials", + type: "TEXT", + maxLength: 20, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OdbcDsnName", + table: "Credentials"); + + migrationBuilder.DropColumn( + name: "OdbcMode", + table: "Credentials"); + } + } +} diff --git a/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.Designer.cs b/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.Designer.cs new file mode 100644 index 0000000..e5aca63 --- /dev/null +++ b/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.Designer.cs @@ -0,0 +1,597 @@ +// +using System; +using CredentialManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CredentialManager.Data.Migrations +{ + [DbContext(typeof(CredentialDbContext))] + [Migration("20260215151630_AddExternalIdRelationships")] + partial class AddExternalIdRelationships + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcDsnName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DatabaseType"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Credentials", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeletionAction") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("DeletionMarkField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DeletionMarkValue") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ExternalIdRelationshipsJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SyncDeletions") + .HasColumnType("INTEGER"); + + b.Property("UseRecordAssociations") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationCredentialId"); + + b.HasIndex("DestinationType"); + + b.HasIndex("IsActive"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SourceCredentialId"); + + b.HasIndex("SourceType"); + + b.ToTable("DataCouplerProfiles", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DeletionSynced") + .HasColumnType("INTEGER"); + + b.Property("DeletionSyncedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsSourceDeleted") + .HasColumnType("INTEGER"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("MappedDestinationField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationEntity"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyValue") + .HasDatabaseName("IX_KeyAssociations_KeyValue"); + + b.HasIndex("LastVerifiedAt"); + + b.HasIndex("RestCredentialName"); + + b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName") + .IsUnique() + .HasDatabaseName("IX_KeyAssociations_Unique"); + + b.ToTable("KeyAssociations", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EnableDeletionSync") + .HasColumnType("INTEGER"); + + b.Property("ExecutionCount") + .HasColumnType("INTEGER"); + + b.Property("IntervalUnit") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IntervalValue") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastExecutionRecordCount") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecutionTime") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledDateTime") + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileSchedules"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ProfileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecordsProcessed") + .HasColumnType("INTEGER"); + + b.Property("RecordsWithErrors") + .HasColumnType("INTEGER"); + + b.Property("ScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SourceInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggeredBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("StartTime"); + + b.HasIndex("Status"); + + b.HasIndex("TriggerType"); + + b.ToTable("ScheduleExecutionHistories", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") + .WithMany() + .HasForeignKey("DestinationCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential") + .WithMany() + .HasForeignKey("SourceCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DestinationCredential"); + + b.Navigation("SourceCredential"); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.HasOne("CredentialManager.Models.ProfileSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.cs b/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.cs new file mode 100644 index 0000000..cb2d995 --- /dev/null +++ b/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Data.Migrations +{ + /// + public partial class AddExternalIdRelationships : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExternalIdRelationshipsJson", + table: "DataCouplerProfiles", + type: "TEXT", + maxLength: 4000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExternalIdRelationshipsJson", + table: "DataCouplerProfiles"); + } + } +} diff --git a/CredentialManager/Data/Migrations/20260216113009_AddDefaultValuesJsonToProfile.Designer.cs b/CredentialManager/Data/Migrations/20260216113009_AddDefaultValuesJsonToProfile.Designer.cs new file mode 100644 index 0000000..7ba2911 --- /dev/null +++ b/CredentialManager/Data/Migrations/20260216113009_AddDefaultValuesJsonToProfile.Designer.cs @@ -0,0 +1,601 @@ +// +using System; +using CredentialManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CredentialManager.Data.Migrations +{ + [DbContext(typeof(CredentialDbContext))] + [Migration("20260216113009_AddDefaultValuesJsonToProfile")] + partial class AddDefaultValuesJsonToProfile + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcDsnName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DatabaseType"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Credentials", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DefaultValuesJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("DeletionAction") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("DeletionMarkField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DeletionMarkValue") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ExternalIdRelationshipsJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SyncDeletions") + .HasColumnType("INTEGER"); + + b.Property("UseRecordAssociations") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationCredentialId"); + + b.HasIndex("DestinationType"); + + b.HasIndex("IsActive"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SourceCredentialId"); + + b.HasIndex("SourceType"); + + b.ToTable("DataCouplerProfiles", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DeletionSynced") + .HasColumnType("INTEGER"); + + b.Property("DeletionSyncedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsSourceDeleted") + .HasColumnType("INTEGER"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("MappedDestinationField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationEntity"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyValue") + .HasDatabaseName("IX_KeyAssociations_KeyValue"); + + b.HasIndex("LastVerifiedAt"); + + b.HasIndex("RestCredentialName"); + + b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName") + .IsUnique() + .HasDatabaseName("IX_KeyAssociations_Unique"); + + b.ToTable("KeyAssociations", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EnableDeletionSync") + .HasColumnType("INTEGER"); + + b.Property("ExecutionCount") + .HasColumnType("INTEGER"); + + b.Property("IntervalUnit") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IntervalValue") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastExecutionRecordCount") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecutionTime") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledDateTime") + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileSchedules"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ProfileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecordsProcessed") + .HasColumnType("INTEGER"); + + b.Property("RecordsWithErrors") + .HasColumnType("INTEGER"); + + b.Property("ScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SourceInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggeredBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("StartTime"); + + b.HasIndex("Status"); + + b.HasIndex("TriggerType"); + + b.ToTable("ScheduleExecutionHistories", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") + .WithMany() + .HasForeignKey("DestinationCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential") + .WithMany() + .HasForeignKey("SourceCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DestinationCredential"); + + b.Navigation("SourceCredential"); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.HasOne("CredentialManager.Models.ProfileSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CredentialManager/Data/Migrations/20260216113009_AddDefaultValuesJsonToProfile.cs b/CredentialManager/Data/Migrations/20260216113009_AddDefaultValuesJsonToProfile.cs new file mode 100644 index 0000000..1ff1a0e --- /dev/null +++ b/CredentialManager/Data/Migrations/20260216113009_AddDefaultValuesJsonToProfile.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Data.Migrations +{ + /// + public partial class AddDefaultValuesJsonToProfile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DefaultValuesJson", + table: "DataCouplerProfiles", + type: "TEXT", + maxLength: 4000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DefaultValuesJson", + table: "DataCouplerProfiles"); + } + } +} diff --git a/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs b/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs index 64a742e..a8a6783 100644 --- a/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs +++ b/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace CredentialManager.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => { @@ -85,6 +85,14 @@ namespace CredentialManager.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property("OdbcDsnName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + b.Property("Port") .HasColumnType("INTEGER"); @@ -138,6 +146,10 @@ namespace CredentialManager.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property("DefaultValuesJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + b.Property("DeletionAction") .HasMaxLength(20) .HasColumnType("TEXT"); @@ -174,6 +186,10 @@ namespace CredentialManager.Migrations .HasMaxLength(20) .HasColumnType("TEXT"); + b.Property("ExternalIdRelationshipsJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + b.Property("FieldMappingJson") .HasMaxLength(4000) .HasColumnType("TEXT"); diff --git a/CredentialManager/Models/CredentialEntity.cs b/CredentialManager/Models/CredentialEntity.cs index b492c10..638e46b 100644 --- a/CredentialManager/Models/CredentialEntity.cs +++ b/CredentialManager/Models/CredentialEntity.cs @@ -61,6 +61,13 @@ public class CredentialEntity [MaxLength(2000)] public string? AdditionalParameters { get; set; } // JSON per parametri aggiuntivi + // ODBC specific fields + [MaxLength(100)] + public string? OdbcDsnName { get; set; } // Nome del DSN ODBC configurato + + [MaxLength(20)] + public string? OdbcMode { get; set; } // Dsn o Custom (OdbcConnectionMode enum) + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } diff --git a/CredentialManager/Models/CredentialModels.cs b/CredentialManager/Models/CredentialModels.cs index e063f63..546bcc3 100644 --- a/CredentialManager/Models/CredentialModels.cs +++ b/CredentialManager/Models/CredentialModels.cs @@ -33,7 +33,24 @@ public enum DatabaseType Oracle, Sqlite, DB2, - SapHana + SapHana, + Odbc +} + +/// +/// Modalità di connessione ODBC +/// +public enum OdbcConnectionMode +{ + /// + /// Utilizzo di un DSN (Data Source Name) configurato + /// + Dsn, + + /// + /// Costruzione manuale della connection string + /// + Custom } /// @@ -52,6 +69,10 @@ public class DatabaseCredential public int CommandTimeout { get; set; } = 30; public bool IgnoreSslErrors { get; set; } = false; public Dictionary? AdditionalParameters { get; set; } + + // ODBC specific properties + public string? OdbcDsnName { get; set; } // Nome del DSN ODBC (se utilizzato) + public OdbcConnectionMode OdbcMode { get; set; } = OdbcConnectionMode.Dsn; // Modalità ODBC (DSN o Custom) } /// @@ -148,17 +169,56 @@ public static class ConnectionStringBuilder DatabaseType.Sqlite => BuildSqliteConnectionString(credential), DatabaseType.DB2 => BuildDb2ConnectionString(credential), DatabaseType.SapHana => BuildSapHanaConnectionString(credential), + DatabaseType.Odbc => BuildOdbcConnectionString(credential), _ => throw new NotSupportedException($"Database type {credential.DatabaseType} not supported") }; } private static string BuildSqlServerConnectionString(DatabaseCredential credential) { - var builder = new List + var builder = new List(); + + // Gestione speciale per SQL Server locale e named instances + // Se l'host contiene '\' (instance name) o '(localdb)', non aggiungere la porta + bool hasInstanceName = credential.Host.Contains('\\') || + credential.Host.StartsWith("(localdb)", StringComparison.OrdinalIgnoreCase); + + if (hasInstanceName) { - $"Server={credential.Host},{credential.Port}", - $"User Id={credential.Username}", - $"Password={credential.Password}", - $"Connection Timeout={credential.CommandTimeout}" - }; + // Per named instances e LocalDB, non includere la porta + builder.Add($"Server={credential.Host}"); + } + else + { + // Per connessioni TCP/IP standard, include host e porta + // Ma solo se la porta non è la default (1433) per localhost + if ((credential.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + credential.Host == "." || + credential.Host == "127.0.0.1") && credential.Port == 1433) + { + // Per localhost con porta default, ometti la porta per usare Named Pipes + builder.Add($"Server={credential.Host}"); + } + else + { + // Per altri casi, usa host,porta + builder.Add($"Server={credential.Host},{credential.Port}"); + } + } + + // Se username è vuoto o è "Integrated", usa Windows Authentication + if (string.IsNullOrWhiteSpace(credential.Username) || + credential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) || + credential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase)) + { + builder.Add("Integrated Security=True"); + } + else + { + // Usa SQL Server Authentication + builder.Add($"User Id={credential.Username}"); + builder.Add($"Password={credential.Password}"); + } + + builder.Add($"Connection Timeout={credential.CommandTimeout}"); // Aggiungi Database solo se specificato if (!string.IsNullOrEmpty(credential.DatabaseName)) @@ -275,6 +335,74 @@ public static class ConnectionStringBuilder return string.Join(";", builder); } + private static string BuildOdbcConnectionString(DatabaseCredential credential) + { + // Se è già presente una connection string personalizzata, utilizzala + if (!string.IsNullOrEmpty(credential.ConnectionString)) + return credential.ConnectionString; + + var builder = new List(); + + // Modalità DSN: usa il DSN configurato + if (credential.OdbcMode == OdbcConnectionMode.Dsn && !string.IsNullOrEmpty(credential.OdbcDsnName)) + { + builder.Add($"DSN={credential.OdbcDsnName}"); + + // Aggiungi credenziali se fornite + if (!string.IsNullOrEmpty(credential.Username)) + builder.Add($"UID={credential.Username}"); + + if (!string.IsNullOrEmpty(credential.Password)) + builder.Add($"PWD={credential.Password}"); + } + // Modalità Custom: costruisci manualmente la connection string + else + { + // Driver (se specificato nei parametri aggiuntivi) + if (credential.AdditionalParameters?.ContainsKey("Driver") == true) + { + builder.Add($"Driver={{{credential.AdditionalParameters["Driver"]}}}"); + } + + // Server/Host + if (!string.IsNullOrEmpty(credential.Host)) + { + builder.Add($"Server={credential.Host}"); + + // Porta (se diversa da 0) + if (credential.Port > 0) + builder.Add($"Port={credential.Port}"); + } + + // Database + if (!string.IsNullOrEmpty(credential.DatabaseName)) + builder.Add($"Database={credential.DatabaseName}"); + + // Credenziali + if (!string.IsNullOrEmpty(credential.Username)) + builder.Add($"UID={credential.Username}"); + + if (!string.IsNullOrEmpty(credential.Password)) + builder.Add($"PWD={credential.Password}"); + } + + // Timeout + if (credential.CommandTimeout > 0) + builder.Add($"Connection Timeout={credential.CommandTimeout}"); + + // Parametri aggiuntivi (escludendo Driver se già aggiunto) + if (credential.AdditionalParameters != null) + { + foreach (var param in credential.AdditionalParameters) + { + if (param.Key != "Driver") // Driver già gestito sopra + builder.Add($"{param.Key}={param.Value}"); + } + } + + return string.Join(";", builder); + } + private static void AddAdditionalParameters(List builder, Dictionary? additionalParams) { if (additionalParams != null) diff --git a/CredentialManager/Models/DataCouplerProfile.cs b/CredentialManager/Models/DataCouplerProfile.cs index be3ca11..cbb8b14 100644 --- a/CredentialManager/Models/DataCouplerProfile.cs +++ b/CredentialManager/Models/DataCouplerProfile.cs @@ -59,6 +59,15 @@ public class DataCouplerProfile // Mapping dei campi salvato come JSON [MaxLength(4000)] public string? FieldMappingJson { get; set; } + + // Default values per i campi di destinazione salvati come JSON + // Formato: { "DestinationField": { "Value": "defaultValue", "Type": "string" } } + [MaxLength(4000)] + public string? DefaultValuesJson { get; set; } + + // External ID Relationships per Salesforce salvate come JSON + [MaxLength(4000)] + public string? ExternalIdRelationshipsJson { get; set; } // Configurazione chiave sorgente e associazioni [MaxLength(200)] diff --git a/CredentialManager/Models/DataCouplerProfileDto.cs b/CredentialManager/Models/DataCouplerProfileDto.cs index 821a418..f3d685c 100644 --- a/CredentialManager/Models/DataCouplerProfileDto.cs +++ b/CredentialManager/Models/DataCouplerProfileDto.cs @@ -30,6 +30,12 @@ public class DataCouplerProfileDto // Mapping dei campi public List? FieldMappings { get; set; } + // Default values per campi destinazione (FieldName -> (Value, Type)) + public Dictionary? DefaultValues { get; set; } + + // External ID Relationships per Salesforce + public List? ExternalIdRelationships { get; set; } + // Configurazione chiave sorgente e associazioni public string? SourceKeyField { get; set; } public bool UseRecordAssociations { get; set; } @@ -47,10 +53,48 @@ public class FieldMappingDto public bool IsRequired { get; set; } public string? DefaultValue { get; set; } public string? Transformation { get; set; } + + /// + /// Lista di relazioni External ID associate a questo campo (per Salesforce) + /// + public List? ExternalIdRelationships { get; set; } } /// -/// DTO per la visualizzazione di un profilo nella lista +/// DTO per External ID Relationship (Salesforce) +/// +public class ExternalIdRelationshipDto +{ + /// + /// Nome della relazione (es. "Account__r") + /// + public string RelationshipName { get; set; } = string.Empty; + + /// + /// Nome dell'oggetto correlato (es. "Account") + /// + public string RelatedObjectName { get; set; } = string.Empty; + + /// + /// Campo External ID dell'oggetto correlato (es. "Country__c") + /// + public string ExternalIdField { get; set; } = string.Empty; + + /// + /// Campo sorgente da cui prendere il valore per l'External ID + /// + public string SourceField { get; set; } = string.Empty; +} + +/// /// DTO per i valori di default +/// +public class DefaultValueDto +{ + public object? Value { get; set; } + public string? Type { get; set; } +} + +/// /// DTO per la visualizzazione di un profilo nella lista /// public class DataCouplerProfileSummaryDto { diff --git a/CredentialManager/Models/MappingModels.cs b/CredentialManager/Models/MappingModels.cs index e69de29..d674a48 100644 --- a/CredentialManager/Models/MappingModels.cs +++ b/CredentialManager/Models/MappingModels.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CredentialManager.Models +{ + /// + /// Tipo di mapping field + /// + public enum MappingType + { + /// + /// Mapping da campo sorgente a campo destinazione + /// + FieldMapping, + + /// + /// Valore di default per campo destinazione + /// + DefaultValue + } + + /// + /// Rappresenta una voce di mapping che può essere: + /// - Un mapping da campo sorgente a campo destinazione + /// - Un valore di default per un campo destinazione + /// + public class FieldMappingEntry + { + /// + /// Tipo di mapping + /// + [JsonPropertyName("type")] + public MappingType Type { get; set; } + + /// + /// Nome del campo sorgente (solo per FieldMapping) + /// + [JsonPropertyName("sourceField")] + public string? SourceField { get; set; } + + /// + /// Nome del campo destinazione + /// + [JsonPropertyName("destinationField")] + public string DestinationField { get; set; } = string.Empty; + + /// + /// Valore di default (solo per DefaultValue) + /// + [JsonPropertyName("defaultValue")] + public object? DefaultValue { get; set; } + + /// + /// Tipo di dato del valore di default (per conversioni corrette) + /// Esempi: "string", "int", "decimal", "boolean", "datetime" + /// + [JsonPropertyName("defaultValueType")] + public string? DefaultValueType { get; set; } + + /// + /// Crea un mapping da campo sorgente a campo destinazione + /// + public static FieldMappingEntry CreateFieldMapping(string sourceField, string destinationField) + { + return new FieldMappingEntry + { + Type = MappingType.FieldMapping, + SourceField = sourceField, + DestinationField = destinationField + }; + } + + /// + /// Crea un valore di default per un campo destinazione + /// + public static FieldMappingEntry CreateDefaultValue(string destinationField, object defaultValue, string? valueType = null) + { + return new FieldMappingEntry + { + Type = MappingType.DefaultValue, + DestinationField = destinationField, + DefaultValue = defaultValue, + DefaultValueType = valueType ?? InferValueType(defaultValue) + }; + } + + /// + /// Determina automaticamente il tipo del valore + /// + private static string InferValueType(object? value) + { + if (value == null) return "string"; + + return value switch + { + string _ => "string", + int _ => "int", + long _ => "long", + decimal _ => "decimal", + double _ => "double", + float _ => "float", + bool _ => "boolean", + DateTime _ => "datetime", + DateTimeOffset _ => "datetimeoffset", + _ => "string" + }; + } + + /// + /// Ottiene una descrizione user-friendly del mapping + /// + public string GetDescription() + { + return Type switch + { + MappingType.FieldMapping => $"{SourceField} → {DestinationField}", + MappingType.DefaultValue => $"{DestinationField} = {DefaultValue ?? "null"} ({DefaultValueType})", + _ => "Unknown" + }; + } + } + + /// + /// Helper per la conversione tra vecchio formato (Dictionary) e nuovo formato (FieldMappingEntry) + /// + public static class MappingConverter + { + /// + /// Converte il vecchio formato Dictionary in lista di FieldMappingEntry + /// + public static List FromDictionary(Dictionary oldMappings) + { + var entries = new List(); + + foreach (var mapping in oldMappings) + { + entries.Add(FieldMappingEntry.CreateFieldMapping(mapping.Key, mapping.Value)); + } + + return entries; + } + + /// + /// Converte una lista di FieldMappingEntry nel vecchio formato Dictionary (solo field mappings) + /// + public static Dictionary ToDictionary(List entries) + { + var dictionary = new Dictionary(); + + foreach (var entry in entries.Where(e => e.Type == MappingType.FieldMapping && !string.IsNullOrEmpty(e.SourceField))) + { + dictionary[entry.SourceField!] = entry.DestinationField; + } + + return dictionary; + } + + /// + /// Ottiene solo i valori di default da una lista di entries + /// + public static Dictionary GetDefaultValues(List entries) + { + var defaults = new Dictionary(); + + foreach (var entry in entries.Where(e => e.Type == MappingType.DefaultValue)) + { + defaults[entry.DestinationField] = (entry.DefaultValue, entry.DefaultValueType); + } + + return defaults; + } + } +} diff --git a/CredentialManager/Services/CredentialService.cs b/CredentialManager/Services/CredentialService.cs index 02bc6a2..b3c3727 100644 --- a/CredentialManager/Services/CredentialService.cs +++ b/CredentialManager/Services/CredentialService.cs @@ -89,6 +89,8 @@ public class CredentialService : ICredentialService AdditionalParameters = credential.AdditionalParameters != null ? JsonSerializer.Serialize(credential.AdditionalParameters) : null, + OdbcDsnName = credential.OdbcDsnName, + OdbcMode = credential.OdbcMode.ToString(), CreatedAt = DateTime.UtcNow, CreatedBy = Environment.UserName }; @@ -110,6 +112,8 @@ public class CredentialService : ICredentialService existing.CommandTimeout = entity.CommandTimeout; existing.IgnoreSslErrors = entity.IgnoreSslErrors; existing.AdditionalParameters = entity.AdditionalParameters; + existing.OdbcDsnName = entity.OdbcDsnName; + existing.OdbcMode = entity.OdbcMode; existing.UpdatedAt = DateTime.UtcNow; _context.Credentials.Update(existing); @@ -695,7 +699,11 @@ public class CredentialService : ICredentialService Password = DecryptSafely(entity.EncryptedPassword, entity.Name, "password"), ConnectionString = entity.ConnectionString, CommandTimeout = entity.CommandTimeout, - IgnoreSslErrors = entity.IgnoreSslErrors + IgnoreSslErrors = entity.IgnoreSslErrors, + OdbcDsnName = entity.OdbcDsnName, + OdbcMode = !string.IsNullOrEmpty(entity.OdbcMode) && Enum.TryParse(entity.OdbcMode, out var odbcMode) + ? odbcMode + : OdbcConnectionMode.Dsn }; if (!string.IsNullOrEmpty(entity.AdditionalParameters)) diff --git a/CredentialManager/Services/DataCouplerProfileService.cs b/CredentialManager/Services/DataCouplerProfileService.cs index d940c16..bd274a8 100644 --- a/CredentialManager/Services/DataCouplerProfileService.cs +++ b/CredentialManager/Services/DataCouplerProfileService.cs @@ -109,6 +109,8 @@ public class DataCouplerProfileService : IDataCouplerProfileService existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.FieldMappingJson = profile.FieldMappingJson; + existingProfile.DefaultValuesJson = profile.DefaultValuesJson; + existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson; existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.IsActive = profile.IsActive; @@ -200,6 +202,100 @@ public class DataCouplerProfileService : IDataCouplerProfileService return new List(); } } + + /// + /// Serializza la lista di External ID Relationships in JSON + /// + public string SerializeExternalIdRelationships(List? relationships) + { + if (relationships == null || !relationships.Any()) + return string.Empty; + + return JsonSerializer.Serialize(relationships, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + + /// + /// + /// Deserializza il JSON delle External ID Relationships + /// + public List DeserializeExternalIdRelationships(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + try + { + return JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }) ?? new List(); + } + catch + { + return new List(); + } + } + + /// + /// Serializza i default values in JSON + /// + public string SerializeDefaultValues(Dictionary? defaultValues) + { + if (defaultValues == null || !defaultValues.Any()) + return string.Empty; + + // Converti in un formato serializzabile (Dictionary) + var serializable = new Dictionary(); + foreach (var entry in defaultValues) + { + serializable[entry.Key] = new DefaultValueDto + { + Value = entry.Value.Value, + Type = entry.Value.Type + }; + } + + return JsonSerializer.Serialize(serializable, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + + /// + /// Deserializza il JSON dei default values + /// + public Dictionary DeserializeDefaultValues(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return new Dictionary(); + + try + { + var deserialized = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + if (deserialized == null) + return new Dictionary(); + + // Converti nel formato tuple + var result = new Dictionary(); + foreach (var entry in deserialized) + { + result[entry.Key] = (entry.Value.Value, entry.Value.Type); + } + + return result; + } + catch + { + return new Dictionary(); + } + } /// /// Converte un DataCouplerProfile in DTO @@ -226,6 +322,8 @@ public class DataCouplerProfileService : IDataCouplerProfileService DestinationTable = profile.DestinationTable, DestinationEndpoint = profile.DestinationEndpoint, FieldMappings = DeserializeFieldMappings(profile.FieldMappingJson), + DefaultValues = DeserializeDefaultValues(profile.DefaultValuesJson), + ExternalIdRelationships = DeserializeExternalIdRelationships(profile.ExternalIdRelationshipsJson), SourceKeyField = profile.SourceKeyField, UseRecordAssociations = profile.UseRecordAssociations }; @@ -254,6 +352,8 @@ public class DataCouplerProfileService : IDataCouplerProfileService DestinationTable = dto.DestinationTable, DestinationEndpoint = dto.DestinationEndpoint, FieldMappingJson = SerializeFieldMappings(dto.FieldMappings), + DefaultValuesJson = SerializeDefaultValues(dto.DefaultValues), + ExternalIdRelationshipsJson = SerializeExternalIdRelationships(dto.ExternalIdRelationships), SourceKeyField = dto.SourceKeyField, UseRecordAssociations = dto.UseRecordAssociations, CreatedBy = createdBy diff --git a/CredentialManager/Services/OdbcDsnDiscoveryService.cs b/CredentialManager/Services/OdbcDsnDiscoveryService.cs new file mode 100644 index 0000000..93f902c --- /dev/null +++ b/CredentialManager/Services/OdbcDsnDiscoveryService.cs @@ -0,0 +1,182 @@ +using Microsoft.Win32; +using Microsoft.Extensions.Logging; + +namespace CredentialManager.Services; + +/// +/// Informazioni su un DSN ODBC +/// +public class OdbcDsnInfo +{ + public string Name { get; set; } = string.Empty; + public string Driver { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsUserDsn { get; set; } // true = User DSN, false = System DSN + public Dictionary Properties { get; set; } = new(); +} + +/// +/// Interfaccia per il servizio di discovery DSN ODBC +/// +public interface IOdbcDsnDiscoveryService +{ + /// + /// Ottiene tutti i DSN ODBC configurati (sia User che System) + /// + List GetAllDsn(); + + /// + /// Ottiene solo i DSN utente + /// + List GetUserDsn(); + + /// + /// Ottiene solo i DSN di sistema + /// + List GetSystemDsn(); + + /// + /// Ottiene i dettagli di un DSN specifico + /// + OdbcDsnInfo? GetDsnDetails(string dsnName, bool isUserDsn = true); + + /// + /// Ottiene la lista dei driver ODBC installati + /// + List GetInstalledDrivers(); +} + +/// +/// Servizio per la scoperta e lettura dei DSN ODBC configurati sul sistema +/// +public class OdbcDsnDiscoveryService : IOdbcDsnDiscoveryService +{ + private readonly ILogger _logger; + + // Percorsi del registro di Windows per ODBC + private const string USER_DSN_PATH = @"SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources"; + private const string SYSTEM_DSN_PATH = @"SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources"; + private const string USER_DSN_DETAILS_PATH = @"SOFTWARE\ODBC\ODBC.INI\"; + private const string SYSTEM_DSN_DETAILS_PATH = @"SOFTWARE\ODBC\ODBC.INI\"; + private const string DRIVERS_PATH = @"SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers"; + + public OdbcDsnDiscoveryService(ILogger logger) + { + _logger = logger; + } + + public List GetAllDsn() + { + var allDsn = new List(); + allDsn.AddRange(GetUserDsn()); + allDsn.AddRange(GetSystemDsn()); + return allDsn; + } + + public List GetUserDsn() + { + return GetDsnFromRegistry(Registry.CurrentUser, USER_DSN_PATH, USER_DSN_DETAILS_PATH, true); + } + + public List GetSystemDsn() + { + return GetDsnFromRegistry(Registry.LocalMachine, SYSTEM_DSN_PATH, SYSTEM_DSN_DETAILS_PATH, false); + } + + public OdbcDsnInfo? GetDsnDetails(string dsnName, bool isUserDsn = true) + { + var allDsn = isUserDsn ? GetUserDsn() : GetSystemDsn(); + return allDsn.FirstOrDefault(d => d.Name.Equals(dsnName, StringComparison.OrdinalIgnoreCase)); + } + + public List GetInstalledDrivers() + { + var drivers = new List(); + + try + { + using var key = Registry.LocalMachine.OpenSubKey(DRIVERS_PATH); + if (key != null) + { + foreach (var driverName in key.GetValueNames()) + { + var value = key.GetValue(driverName)?.ToString(); + if (value == "Installed") + { + drivers.Add(driverName); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nella lettura dei driver ODBC dal registro"); + } + + return drivers.OrderBy(d => d).ToList(); + } + + private List GetDsnFromRegistry(RegistryKey rootKey, string dsnPath, string detailsPath, bool isUserDsn) + { + var dsnList = new List(); + + try + { + using var dsnKey = rootKey.OpenSubKey(dsnPath); + if (dsnKey == null) + { + _logger.LogWarning("Chiave registro ODBC non trovata: {Path}", dsnPath); + return dsnList; + } + + foreach (var dsnName in dsnKey.GetValueNames()) + { + try + { + var driver = dsnKey.GetValue(dsnName)?.ToString(); + if (string.IsNullOrEmpty(driver)) + continue; + + var dsnInfo = new OdbcDsnInfo + { + Name = dsnName, + Driver = driver, + IsUserDsn = isUserDsn + }; + + // Leggi i dettagli del DSN + using var detailKey = rootKey.OpenSubKey(detailsPath + dsnName); + if (detailKey != null) + { + foreach (var valueName in detailKey.GetValueNames()) + { + var value = detailKey.GetValue(valueName)?.ToString(); + if (!string.IsNullOrEmpty(value)) + { + dsnInfo.Properties[valueName] = value; + + // Popola proprietà comuni + if (valueName.Equals("Description", StringComparison.OrdinalIgnoreCase)) + dsnInfo.Description = value; + } + } + } + + dsnList.Add(dsnInfo); + _logger.LogDebug("DSN trovato: {Name} ({Driver}) - Type: {Type}", + dsnName, driver, isUserDsn ? "User" : "System"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Errore nella lettura del DSN: {DsnName}", dsnName); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nella lettura dei DSN ODBC dal registro"); + } + + return dsnList; + } +} diff --git a/CredentialManager/Services/ProfileScheduleService.cs b/CredentialManager/Services/ProfileScheduleService.cs index 9aaa6b3..af7aaee 100644 --- a/CredentialManager/Services/ProfileScheduleService.cs +++ b/CredentialManager/Services/ProfileScheduleService.cs @@ -93,6 +93,9 @@ public class ProfileScheduleService : IProfileScheduleService existingSchedule.IntervalValue = schedule.IntervalValue; existingSchedule.IntervalUnit = schedule.IntervalUnit; existingSchedule.IsActive = schedule.IsActive; + existingSchedule.EnableDeletionSync = schedule.EnableDeletionSync; + existingSchedule.SourceDatabaseOverride = schedule.SourceDatabaseOverride; + existingSchedule.DestinationDatabaseOverride = schedule.DestinationDatabaseOverride; existingSchedule.UpdatedAt = DateTime.UtcNow; // Ricalcola la prossima esecuzione diff --git a/CredentialManager/design_time_temp.db b/CredentialManager/design_time_temp.db index 0482876..2f868d4 100644 Binary files a/CredentialManager/design_time_temp.db and b/CredentialManager/design_time_temp.db differ diff --git a/DataConnection/CredentialManagement/Models/CredentialExtensions.cs b/DataConnection/CredentialManagement/Models/CredentialExtensions.cs index 6065ec9..3ae84ea 100644 --- a/DataConnection/CredentialManagement/Models/CredentialExtensions.cs +++ b/DataConnection/CredentialManagement/Models/CredentialExtensions.cs @@ -21,6 +21,7 @@ public static class CredentialExtensions CredentialManager.Models.DatabaseType.Sqlite => DataConnection.Enums.DatabaseType.Sqlite, CredentialManager.Models.DatabaseType.DB2 => DataConnection.Enums.DatabaseType.DB2, CredentialManager.Models.DatabaseType.SapHana => DataConnection.Enums.DatabaseType.SapHana, + CredentialManager.Models.DatabaseType.Odbc => DataConnection.Enums.DatabaseType.Odbc, _ => throw new NotSupportedException($"Database type {credentialDbType} not supported") }; } @@ -39,6 +40,7 @@ public static class CredentialExtensions DataConnection.Enums.DatabaseType.Sqlite => CredentialManager.Models.DatabaseType.Sqlite, DataConnection.Enums.DatabaseType.DB2 => CredentialManager.Models.DatabaseType.DB2, DataConnection.Enums.DatabaseType.SapHana => CredentialManager.Models.DatabaseType.SapHana, + DataConnection.Enums.DatabaseType.Odbc => CredentialManager.Models.DatabaseType.Odbc, _ => throw new NotSupportedException($"Database type {dataConnectionDbType} not supported") }; } diff --git a/DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs b/DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs index cae6f67..c5309d7 100644 --- a/DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs +++ b/DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs @@ -250,6 +250,7 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService CredentialManager.Models.DatabaseType.PostgreSql => await TestPostgreSqlConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Oracle => await TestOracleConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Sqlite => await TestSqliteConnection(connectionString, credential), + CredentialManager.Models.DatabaseType.Odbc => await TestOdbcConnection(connectionString, credential), _ => (false, $"Test di connessione non implementato per {credential.DatabaseType}") }; } @@ -344,6 +345,65 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService return (false, $"Errore SQLite: {ex.Message}"); } } + + private async Task<(bool Success, string Message)> TestOdbcConnection(string connectionString, DatabaseCredential credential) + { + try + { + using var connection = new System.Data.Odbc.OdbcConnection(connectionString); + await connection.OpenAsync(); + + // Non eseguiamo query di test perché alcuni database (come SAP HANA) + // hanno sintassi specifiche e potrebbero fallire anche con SELECT 1 + // Ci limitiamo a testare l'apertura della connessione + + var details = new System.Text.StringBuilder(); + details.AppendLine("Connessione ODBC riuscita!"); + details.AppendLine(); + details.AppendLine("Dettagli:"); + + if (credential.OdbcMode == CredentialManager.Models.OdbcConnectionMode.Dsn && !string.IsNullOrEmpty(credential.OdbcDsnName)) + { + details.AppendLine($"- DSN: {credential.OdbcDsnName}"); + details.AppendLine($"- Tipo: {(credential.OdbcMode == CredentialManager.Models.OdbcConnectionMode.Dsn ? "DSN" : "Custom")}"); + } + else + { + details.AppendLine($"- Modalità: Custom Connection String"); + if (!string.IsNullOrEmpty(credential.Host)) + details.AppendLine($"- Server: {credential.Host}" + (credential.Port > 0 ? $":{credential.Port}" : "")); + if (!string.IsNullOrEmpty(credential.DatabaseName)) + details.AppendLine($"- Database: {credential.DatabaseName}"); + } + + details.AppendLine($"- Driver: {connection.Driver}"); + details.AppendLine($"- Server Version: {connection.ServerVersion}"); + details.AppendLine($"- Database: {connection.Database}"); + details.AppendLine($"- Timeout: {credential.CommandTimeout}s"); + + return (true, details.ToString()); + } + catch (System.Data.Odbc.OdbcException odbcEx) + { + var errorDetails = new System.Text.StringBuilder(); + errorDetails.AppendLine($"Errore ODBC: {odbcEx.Message}"); + errorDetails.AppendLine(); + errorDetails.AppendLine("Dettagli errori:"); + + foreach (System.Data.Odbc.OdbcError error in odbcEx.Errors) + { + errorDetails.AppendLine($"- [{error.SQLState}] {error.Message}"); + errorDetails.AppendLine($" Source: {error.Source}"); + } + + return (false, errorDetails.ToString()); + } + catch (Exception ex) + { + return (false, $"Errore ODBC: {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 25c54a6..2726047 100644 --- a/DataConnection/DB/EF/DatabaseSchemaProviderFactory.cs +++ b/DataConnection/DB/EF/DatabaseSchemaProviderFactory.cs @@ -19,8 +19,7 @@ public class DatabaseSchemaProviderFactory { return databaseType switch { - DatabaseType.SqlServer => new SqlServerSchemaProvider(), - // Aggiungere qui altri provider quando implementati + DatabaseType.SqlServer => new SqlServerSchemaProvider(), DatabaseType.Odbc => new OdbcSchemaProvider(), // Aggiungere qui altri provider quando implementati // DatabaseType.MySql => new MySqlSchemaProvider(), // DatabaseType.PostgreSql => new PostgreSqlSchemaProvider(), // DatabaseType.Oracle => new OracleSchemaProvider(), diff --git a/DataConnection/DB/EF/DbManagerOptions.cs b/DataConnection/DB/EF/DbManagerOptions.cs index 3ca08fe..7244b64 100644 --- a/DataConnection/DB/EF/DbManagerOptions.cs +++ b/DataConnection/DB/EF/DbManagerOptions.cs @@ -79,6 +79,16 @@ public class DbManagerOptions DbContextConfigurator = options => options.UseSqlServer(BuildFullConnectionString(), sqlOptions => sqlOptions.CommandTimeout(CommandTimeout)); break; + case DatabaseType.Odbc: + // Per ODBC non c'è un provider EF Core specifico, useremo connessioni dirette + // Il DatabaseDiscoveryService può essere null per ODBC + DatabaseDiscoveryService = null!; + DbContextConfigurator = options => + { + // ODBC non ha un provider EF Core nativo, quindi configuriamo un provider generico + // Le query verranno eseguite tramite connessioni dirette ADO.NET + }; + break; default: // Per altri database, configuriamo un configuratore di base che non fa nulla // Il test di connessione userà un approccio diverso diff --git a/DataConnection/DB/EF/EFCoreDatabaseManager.cs b/DataConnection/DB/EF/EFCoreDatabaseManager.cs index 51d716b..89678b5 100644 --- a/DataConnection/DB/EF/EFCoreDatabaseManager.cs +++ b/DataConnection/DB/EF/EFCoreDatabaseManager.cs @@ -476,6 +476,8 @@ public class EFCoreDatabaseManager : IDatabaseManager { case Enums.DatabaseType.SqlServer: return new SqlConnection(connectionString); + case Enums.DatabaseType.Odbc: + return new System.Data.Odbc.OdbcConnection(connectionString); // Aggiungi altri tipi di database quando necessario // case Enums.DatabaseType.MySQL: // return new MySqlConnection(connectionString); diff --git a/DataConnection/DB/EF/SchemaProviders/OdbcSchemaProvider.cs b/DataConnection/DB/EF/SchemaProviders/OdbcSchemaProvider.cs new file mode 100644 index 0000000..6087e9b --- /dev/null +++ b/DataConnection/DB/EF/SchemaProviders/OdbcSchemaProvider.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Odbc; +using System.Linq; +using System.Threading.Tasks; +using DataConnection.Interfaces; + +namespace DataConnection.EF.SchemaProviders; + +/// +/// Provider di schema per database ODBC generici +/// Utilizza le funzioni ODBC standard per ottenere metadati del database +/// +public class OdbcSchemaProvider : IDatabaseSchemaProvider +{ + public async Task>> GetDatabaseSchemaAsync(string connectionString) + { + var result = new Dictionary>(); + + try + { + using var connection = new OdbcConnection(connectionString); + await connection.OpenAsync(); + + Console.WriteLine($"ODBC Schema Provider - Connesso a: {connection.Database}"); + Console.WriteLine($"Driver: {connection.Driver}"); + Console.WriteLine($"Server Version: {connection.ServerVersion}"); + + // Ottieni le tabelle dal database usando GetSchema + var tablesSchema = connection.GetSchema("Tables"); + + // Filtra solo le tabelle utente (esclude views, system tables, ecc.) + var userTables = tablesSchema.AsEnumerable() + .Where(row => + { + var tableType = row["TABLE_TYPE"].ToString(); + return tableType == "TABLE" || tableType == "BASE TABLE"; + }) + .Select(row => new + { + Schema = row.IsNull("TABLE_SCHEM") ? null : row["TABLE_SCHEM"].ToString(), + TableName = row["TABLE_NAME"].ToString() ?? string.Empty, + FullName = GetFullTableName(row) + }) + .Where(t => !string.IsNullOrEmpty(t.TableName)) + .ToList(); + + Console.WriteLine($"Trovate {userTables.Count} tabelle utente"); + + // Per ogni tabella, ottieni le colonne + foreach (var table in userTables) + { + try + { + var columns = await GetTableColumnsAsync(connection, table.Schema, table.TableName); + + if (columns.Any()) + { + result[table.FullName] = columns; + Console.WriteLine($"Tabella {table.FullName}: {columns.Count()} colonne"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Errore nel leggere le colonne della tabella {table.FullName}: {ex.Message}"); + } + } + + if (result.Count == 0) + { + Console.WriteLine("ATTENZIONE: Nessuna tabella trovata o nessuna colonna leggibile"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Errore in OdbcSchemaProvider.GetDatabaseSchemaAsync: {ex.Message}"); + throw; + } + + return result; + } + + private static string GetFullTableName(DataRow tableRow) + { + var schema = tableRow.IsNull("TABLE_SCHEM") ? null : tableRow["TABLE_SCHEM"].ToString(); + var tableName = tableRow["TABLE_NAME"].ToString() ?? string.Empty; + + if (!string.IsNullOrEmpty(schema) && schema != "dbo") + return $"{schema}.{tableName}"; + + return tableName; + } + + private async Task> GetTableColumnsAsync(OdbcConnection connection, string? schemaName, string tableName) + { + var columns = new List(); + + try + { + // Usa GetSchema per ottenere le colonne + // Alcuni driver ODBC supportano restrizioni per schema e table name + string?[] restrictions = new string?[4]; + restrictions[0] = null; // Catalog + restrictions[1] = schemaName; // Schema + restrictions[2] = tableName; // Table name + restrictions[3] = null; // Column name + + DataTable columnsSchema; + + try + { + columnsSchema = connection.GetSchema("Columns", restrictions); + } + catch + { + // Alcuni driver non supportano le restrizioni, proviamo senza + columnsSchema = connection.GetSchema("Columns"); + + // Filtra manualmente per table name + columnsSchema = columnsSchema.AsEnumerable() + .Where(row => row["TABLE_NAME"].ToString() == tableName) + .CopyToDataTable(); + } + + // Ottieni le primary keys per questa tabella + var primaryKeys = GetPrimaryKeys(connection, schemaName, tableName); + + // Ottieni le foreign keys per questa tabella + var foreignKeys = GetForeignKeys(connection, schemaName, tableName); + + foreach (DataRow columnRow in columnsSchema.Rows) + { + var columnName = columnRow["COLUMN_NAME"].ToString() ?? string.Empty; + + if (string.IsNullOrEmpty(columnName)) + continue; + + var dataType = columnRow["TYPE_NAME"].ToString() ?? "unknown"; + var isNullable = ParseNullable(columnRow["IS_NULLABLE"]); + + // Formatta il tipo di dati con dimensioni se disponibili + var formattedDataType = FormatDataType(dataType, columnRow); + + var columnInfo = new DbColumnInfo + { + Name = columnName, + DataType = formattedDataType, + IsNullable = isNullable, + IsPrimaryKey = primaryKeys.Contains(columnName), + IsForeignKey = foreignKeys.ContainsKey(columnName), + ReferencedTable = foreignKeys.ContainsKey(columnName) ? foreignKeys[columnName].ReferencedTable : null, + ReferencedColumn = foreignKeys.ContainsKey(columnName) ? foreignKeys[columnName].ReferencedColumn : null + }; + + columns.Add(columnInfo); + } + } + catch (Exception ex) + { + Console.WriteLine($"Errore nel recuperare le colonne per {tableName}: {ex.Message}"); + } + + return columns; + } + + private HashSet GetPrimaryKeys(OdbcConnection connection, string? schemaName, string tableName) + { + var primaryKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + string?[] restrictions = new string?[4]; + restrictions[0] = null; // Catalog + restrictions[1] = schemaName; // Schema + restrictions[2] = tableName; // Table name + restrictions[3] = null; // Column name + + var pkSchema = connection.GetSchema("PrimaryKeys", restrictions); + + foreach (DataRow row in pkSchema.Rows) + { + var columnName = row["COLUMN_NAME"].ToString(); + if (!string.IsNullOrEmpty(columnName)) + primaryKeys.Add(columnName); + } + } + catch (Exception ex) + { + // Alcuni driver ODBC non supportano PrimaryKeys schema collection + Console.WriteLine($"GetSchema PrimaryKeys non supportato: {ex.Message}"); + } + + return primaryKeys; + } + + private Dictionary GetForeignKeys(OdbcConnection connection, string? schemaName, string tableName) + { + var foreignKeys = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + string?[] restrictions = new string?[4]; + restrictions[0] = null; // Catalog + restrictions[1] = schemaName; // Schema + restrictions[2] = tableName; // Table name + restrictions[3] = null; // Column name + + var fkSchema = connection.GetSchema("ForeignKeys", restrictions); + + foreach (DataRow row in fkSchema.Rows) + { + var columnName = row["FKCOLUMN_NAME"].ToString(); + var referencedTable = row["PKTABLE_NAME"].ToString(); + var referencedColumn = row["PKCOLUMN_NAME"].ToString(); + + if (!string.IsNullOrEmpty(columnName) && !string.IsNullOrEmpty(referencedTable) && !string.IsNullOrEmpty(referencedColumn)) + { + foreignKeys[columnName] = (referencedTable, referencedColumn); + } + } + } + catch (Exception ex) + { + // Alcuni driver ODBC non supportano ForeignKeys schema collection + Console.WriteLine($"GetSchema ForeignKeys non supportato: {ex.Message}"); + } + + return foreignKeys; + } + + private bool ParseNullable(object? isNullableValue) + { + if (isNullableValue == null || isNullableValue == DBNull.Value) + return true; + + var strValue = isNullableValue.ToString()?.ToUpperInvariant(); + + return strValue switch + { + "YES" => true, + "NO" => false, + "1" => true, + "0" => false, + _ => true // Default a nullable se non riusciamo a determinarlo + }; + } + + private string FormatDataType(string dataType, DataRow columnRow) + { + try + { + // Prova ad ottenere lunghezza/precisione/scala + var columnSize = columnRow.IsNull("COLUMN_SIZE") ? 0 : Convert.ToInt32(columnRow["COLUMN_SIZE"]); + var decimalDigits = columnRow.IsNull("DECIMAL_DIGITS") ? 0 : Convert.ToInt32(columnRow["DECIMAL_DIGITS"]); + + var upperDataType = dataType.ToUpperInvariant(); + + // Tipi numerici con precisione e scala + if (upperDataType.Contains("DECIMAL") || upperDataType.Contains("NUMERIC")) + { + if (columnSize > 0 && decimalDigits >= 0) + return $"{dataType}({columnSize},{decimalDigits})"; + } + // Tipi stringa con lunghezza + else if (upperDataType.Contains("CHAR") || upperDataType.Contains("VARCHAR") || + upperDataType.Contains("TEXT") || upperDataType.Contains("STRING")) + { + if (columnSize > 0 && columnSize < 8000) + return $"{dataType}({columnSize})"; + else if (columnSize >= 8000) + return $"{dataType}(MAX)"; + } + // Tipi floating point + else if (upperDataType.Contains("FLOAT") || upperDataType.Contains("DOUBLE") || upperDataType.Contains("REAL")) + { + if (columnSize > 0) + return $"{dataType}({columnSize})"; + } + + return dataType; + } + catch + { + return dataType; + } + } + + public async Task> GetAvailableDatabasesAsync(string connectionString) + { + var databases = new List(); + + try + { + using var connection = new OdbcConnection(connectionString); + await connection.OpenAsync(); + + // Tenta di ottenere i database disponibili usando GetSchema + try + { + var catalogsSchema = connection.GetSchema("Catalogs"); + + foreach (DataRow row in catalogsSchema.Rows) + { + var catalogName = row["CATALOG_NAME"]?.ToString(); + if (!string.IsNullOrEmpty(catalogName)) + databases.Add(catalogName); + } + } + catch (Exception ex) + { + Console.WriteLine($"GetSchema Catalogs non supportato: {ex.Message}"); + + // Fallback: alcuni driver potrebbero usare "Databases" invece di "Catalogs" + try + { + var dbSchema = connection.GetSchema("Databases"); + foreach (DataRow row in dbSchema.Rows) + { + var dbName = row[0]?.ToString(); // Prima colonna dovrebbe essere il nome + if (!string.IsNullOrEmpty(dbName)) + databases.Add(dbName); + } + } + catch + { + // Se nemmeno questo funziona, restituisci il database corrente + if (!string.IsNullOrEmpty(connection.Database)) + databases.Add(connection.Database); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Errore in GetAvailableDatabasesAsync: {ex.Message}"); + } + + return databases; + } + + public async Task> GetTableNamesAsync(string connectionString) + { + var tableNames = new List(); + + try + { + using var connection = new OdbcConnection(connectionString); + await connection.OpenAsync(); + + var tablesSchema = connection.GetSchema("Tables"); + + tableNames = tablesSchema.AsEnumerable() + .Where(row => + { + var tableType = row["TABLE_TYPE"].ToString(); + return tableType == "TABLE" || tableType == "BASE TABLE"; + }) + .Select(row => GetFullTableName(row)) + .Where(name => !string.IsNullOrEmpty(name)) + .ToList(); + } + catch (Exception ex) + { + Console.WriteLine($"Errore in GetTableNamesAsync: {ex.Message}"); + } + + return tableNames; + } + + public async Task> GetTableSchemaAsync(string connectionString, string tableName) + { + try + { + using var connection = new OdbcConnection(connectionString); + await connection.OpenAsync(); + + // Separa schema e nome tabella se presente il punto + string? schemaName = null; + string actualTableName = tableName; + + if (tableName.Contains('.')) + { + var parts = tableName.Split('.'); + schemaName = parts[0]; + actualTableName = parts[1]; + } + + return await GetTableColumnsAsync(connection, schemaName, actualTableName); + } + catch (Exception ex) + { + Console.WriteLine($"Errore in GetTableSchemaAsync per {tableName}: {ex.Message}"); + return Enumerable.Empty(); + } + } +} diff --git a/DataConnection/DB/Enums/DatabaseType.cs b/DataConnection/DB/Enums/DatabaseType.cs index a958036..a3c453f 100644 --- a/DataConnection/DB/Enums/DatabaseType.cs +++ b/DataConnection/DB/Enums/DatabaseType.cs @@ -11,5 +11,6 @@ public enum DatabaseType Oracle, Sqlite, DB2, - SapHana + SapHana, + Odbc } diff --git a/DataConnection/DB/OdbcDatabaseManager.cs b/DataConnection/DB/OdbcDatabaseManager.cs new file mode 100644 index 0000000..68bca5f --- /dev/null +++ b/DataConnection/DB/OdbcDatabaseManager.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Odbc; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using DataConnection.EF.SchemaProviders; +using DataConnection.Interfaces; + +namespace DataConnection.DB; + +/// +/// Database manager per connessioni ODBC dirette (senza Entity Framework) +/// +public class OdbcDatabaseManager : IDatabaseManager +{ + private readonly string _connectionString; + private readonly OdbcSchemaProvider _schemaProvider; + private string _currentDatabase = string.Empty; + + public OdbcDatabaseManager(string connectionString) + { + _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + _schemaProvider = new OdbcSchemaProvider(); + } + + public async Task TestConnectionAsync() + { + try + { + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + return true; + } + catch + { + return false; + } + } + + 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 with LINQ expressions is not supported for ODBC. Use ExecuteQueryAsync instead."); + } + + public Task GetByIdAsync(object id) where T : class + { + throw new NotSupportedException("GetByIdAsync is not supported for ODBC. Use ExecuteQueryAsync with WHERE clause instead."); + } + + public Task> ExecuteQueryAsync(string sql, params object[] parameters) where T : class + { + throw new NotSupportedException("ExecuteQueryAsync with entity type is not supported for ODBC. Use ExecuteRawQueryAsync instead."); + } + + public async Task>> ExecuteRawQueryAsync(string sql, string databaseName = "", params object[] parameters) + { + var results = new List>(); + + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + // Cambia database se specificato + if (!string.IsNullOrEmpty(databaseName) && databaseName != _currentDatabase) + { + await connection.ChangeDatabaseAsync(databaseName); + _currentDatabase = databaseName; + } + + using var command = new OdbcCommand(sql, connection); + + // Aggiungi parametri + if (parameters != null && parameters.Length > 0) + { + for (int i = 0; i < parameters.Length; i++) + { + command.Parameters.Add(new OdbcParameter($"@p{i}", parameters[i] ?? DBNull.Value)); + } + } + + using var reader = await command.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var row = new Dictionary(); + for (int i = 0; i < reader.FieldCount; i++) + { + var fieldName = reader.GetName(i); + var value = reader.IsDBNull(i) ? DBNull.Value : reader.GetValue(i); + row[fieldName] = value; + } + results.Add(row); + } + + return results; + } + + public async Task ExecuteCommandAsync(string sql, params object[] parameters) + { + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new OdbcCommand(sql, connection); + + if (parameters != null && parameters.Length > 0) + { + for (int i = 0; i < parameters.Length; i++) + { + command.Parameters.Add(new OdbcParameter($"@p{i}", parameters[i] ?? DBNull.Value)); + } + } + + return await command.ExecuteNonQueryAsync(); + } + + public async Task> GetAvailableDatabasesAsync() + { + var databases = await _schemaProvider.GetAvailableDatabasesAsync(_connectionString); + return databases.ToList(); + } + + public async Task ChangeDatabaseAsync(string databaseName) + { + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + await connection.ChangeDatabaseAsync(databaseName); + _currentDatabase = databaseName; + } + + public async Task>> GetDatabaseSchemaAsync() + { + return await _schemaProvider.GetDatabaseSchemaAsync(_connectionString); + } + + public async Task> GetTableNamesAsync() + { + return await _schemaProvider.GetTableNamesAsync(_connectionString); + } + + public async Task> GetTableSchemaAsync(string tableName) + { + return await _schemaProvider.GetTableSchemaAsync(_connectionString, tableName); + } + + public async Task>> GetAllRecordsAsync(string tableName) + { + var query = $"SELECT * FROM {tableName}"; + var results = await ExecuteRawQueryAsync(query); + return results; + } + + public async Task GetPrimaryKeyFieldAsync(string tableName) + { + try + { + var schema = await GetTableSchemaAsync(tableName); + var pkColumn = schema.FirstOrDefault(c => c.IsPrimaryKey); + return pkColumn?.Name; + } + catch + { + return null; + } + } + + public async Task>> ExecuteQueryAsync(string query, int? maxRows = null) + { + var results = new List>(); + + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new OdbcCommand(query, connection); + if (maxRows.HasValue) + { + command.CommandText = WrapQueryWithLimit(query, maxRows.Value); + } + + using var reader = await command.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var row = new Dictionary(); + for (int i = 0; i < reader.FieldCount; i++) + { + var fieldName = reader.GetName(i); + var value = reader.IsDBNull(i) ? null : reader.GetValue(i); + row[fieldName] = value; + } + results.Add(row); + } + + return results; + } + + public async Task ExecuteNonQueryAsync(string query) + { + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new OdbcCommand(query, connection); + return await command.ExecuteNonQueryAsync(); + } + + public async Task ExecuteScalarAsync(string query) + { + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new OdbcCommand(query, connection); + return await command.ExecuteScalarAsync(); + } + + public async Task InsertAsync(string tableName, IDictionary data) + { + var columns = string.Join(", ", data.Keys.Select(k => $"[{k}]")); + var parameters = string.Join(", ", data.Keys.Select((_, i) => $"?")); + + var query = $"INSERT INTO {tableName} ({columns}) VALUES ({parameters})"; + + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new OdbcCommand(query, connection); + + foreach (var value in data.Values) + { + command.Parameters.Add(new OdbcParameter { Value = value ?? DBNull.Value }); + } + + return await command.ExecuteNonQueryAsync(); + } + + public async Task UpdateAsync(string tableName, IDictionary data, IDictionary whereClause) + { + var setClause = string.Join(", ", data.Keys.Select(k => $"[{k}] = ?")); + var whereConditions = string.Join(" AND ", whereClause.Keys.Select(k => $"[{k}] = ?")); + + var query = $"UPDATE {tableName} SET {setClause} WHERE {whereConditions}"; + + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new OdbcCommand(query, connection); + + // Aggiungi parametri SET + foreach (var value in data.Values) + { + command.Parameters.Add(new OdbcParameter { Value = value ?? DBNull.Value }); + } + + // Aggiungi parametri WHERE + foreach (var value in whereClause.Values) + { + command.Parameters.Add(new OdbcParameter { Value = value ?? DBNull.Value }); + } + + return await command.ExecuteNonQueryAsync(); + } + + public async Task DeleteAsync(string tableName, IDictionary whereClause) + { + var whereConditions = string.Join(" AND ", whereClause.Keys.Select(k => $"[{k}] = ?")); + var query = $"DELETE FROM {tableName} WHERE {whereConditions}"; + + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + using var command = new OdbcCommand(query, connection); + + foreach (var value in whereClause.Values) + { + command.Parameters.Add(new OdbcParameter { Value = value ?? DBNull.Value }); + } + + return await command.ExecuteNonQueryAsync(); + } + + public async Task BulkInsertAsync(string tableName, IEnumerable> dataList) + { + int totalInserted = 0; + + using var connection = new OdbcConnection(_connectionString); + await connection.OpenAsync(); + + using var transaction = connection.BeginTransaction(); + + try + { + foreach (var data in dataList) + { + var columns = string.Join(", ", data.Keys.Select(k => $"[{k}]")); + var parameters = string.Join(", ", data.Keys.Select((_, i) => $"?")); + + var query = $"INSERT INTO {tableName} ({columns}) VALUES ({parameters})"; + + using var command = new OdbcCommand(query, connection, transaction); + + foreach (var value in data.Values) + { + command.Parameters.Add(new OdbcParameter { Value = value ?? DBNull.Value }); + } + + totalInserted += await command.ExecuteNonQueryAsync(); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + + return totalInserted; + } + + /// + /// Wrappa la query con LIMIT/TOP a seconda del dialetto SQL + /// Nota: ODBC non ha una sintassi standard, quindi usiamo TOP (SQL Server style) + /// che è supportato dalla maggior parte dei driver + /// + private string WrapQueryWithLimit(string query, int maxRows) + { + // Verifica se la query ha già un LIMIT o TOP + var upperQuery = query.Trim().ToUpperInvariant(); + + if (upperQuery.Contains("LIMIT ") || upperQuery.Contains("TOP ")) + { + return query; // Query già limitata + } + + // Prova con SELECT TOP (SQL Server, SAP HANA) + if (upperQuery.StartsWith("SELECT ")) + { + return query.Insert(7, $"TOP {maxRows} "); + } + + // Fallback: aggiungi LIMIT alla fine (MySQL, PostgreSQL style) + return $"{query} LIMIT {maxRows}"; + } + + public void Dispose() + { + // Nessuna risorsa da rilasciare per ODBC diretto + } +} diff --git a/DataConnection/DataConnection.csproj b/DataConnection/DataConnection.csproj index 6230e81..20d0a33 100644 --- a/DataConnection/DataConnection.csproj +++ b/DataConnection/DataConnection.csproj @@ -15,6 +15,7 @@ + diff --git a/DataConnection/REST/Implementations/SalesforceServiceClient.cs b/DataConnection/REST/Implementations/SalesforceServiceClient.cs index ee7d5d7..0977582 100644 --- a/DataConnection/REST/Implementations/SalesforceServiceClient.cs +++ b/DataConnection/REST/Implementations/SalesforceServiceClient.cs @@ -175,70 +175,43 @@ namespace DataConnection.REST.Implementations var entities = new List(); try { - // First, get list of all SObjects + // Step 1: get list of all SObjects (1 API call) var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/"; var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken); response.EnsureSuccessStatusCode(); - var sobjectsResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null) + var sobjectsResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (sobjectsResponse?.SObjects != null) { - // For demo purposes, limit to first 20 objects to avoid too many API calls - var limitedSObjects = sobjectsResponse.SObjects.ToList(); + var sObjectNames = sobjectsResponse.SObjects + .Where(s => !string.IsNullOrEmpty(s.Name)) + .Select(s => s.Name!) + .ToList(); - // Process SObjects in parallel for better performance - var semaphore = new SemaphoreSlim(20, 20); // Limit concurrent requests to 5 - var tasks = limitedSObjects.Where(sobject => !string.IsNullOrEmpty(sobject.Name)) - .Select(async sobject => + Console.WriteLine($"DiscoverEntities: {sObjectNames.Count} SObjects. Using Composite Batch API ({Math.Ceiling((double)sObjectNames.Count / 25)} request(s) instead of {sObjectNames.Count})."); + + // Step 2: batch describe all SObjects via Composite Batch API (25 per request) + var describeResults = await BatchDescribeSObjectsAsync(sObjectNames, cancellationToken); + + foreach (var sobject in sobjectsResponse.SObjects) + { + if (string.IsNullOrEmpty(sobject.Name)) continue; + if (!describeResults.TryGetValue(sobject.Name, out var describeResult) || describeResult?.Fields == null) + continue; + + var entityInfo = new RestEntityInfo { Name = sobject.Name }; + foreach (var field in describeResult.Fields) { - await semaphore.WaitAsync(cancellationToken); - try + if (string.IsNullOrEmpty(field.Name)) continue; + entityInfo.Properties.Add(new RestPropertyInfo { - // Get detailed field information for each SObject - var describeEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{sobject.Name}/describe/"; - var describeResponse = await _httpClient.GetAsync(describeEndpoint, cancellationToken); - - if (describeResponse.IsSuccessStatusCode) - { - var describeResult = await describeResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - if (describeResult?.Fields != null) - { - var entityInfo = new RestEntityInfo - { - Name = sobject.Name - }; - - foreach (var field in describeResult.Fields) - { - if (string.IsNullOrEmpty(field.Name)) continue; - - var propInfo = new RestPropertyInfo - { - Name = field.Name, - Type = field.Type ?? "string", - IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) - }; - entityInfo.Properties.Add(propInfo); - } - - return entityInfo; - } - } - return null; - } - catch (Exception ex) - { - Console.WriteLine($"Error describing SObject {sobject.Name}: {ex.Message}"); - return null; - } - finally - { - semaphore.Release(); - } - }); - - var results = await Task.WhenAll(tasks); - entities.AddRange(results.Where(result => result != null)!); + Name = field.Name, + Type = field.Type ?? "string", + IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) + }); + } + entities.Add(entityInfo); + } } } catch (HttpRequestException ex) @@ -382,6 +355,116 @@ namespace DataConnection.REST.Implementations return null; } + /// + /// Describes multiple SObjects in batches using the Salesforce Composite Batch API. + /// Reduces API calls from N (one per object) to ceil(N/25) by grouping up to 25 describe + /// requests per Composite Batch call. + /// + private async Task> BatchDescribeSObjectsAsync( + List sObjectNames, CancellationToken cancellationToken) + { + const int maxBatchSize = 25; + var allResults = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Split into batches of 25 (Salesforce Composite Batch limit) + var batches = new List<(List Names, int BatchNumber)>(); + for (int i = 0; i < sObjectNames.Count; i += maxBatchSize) + { + var chunk = sObjectNames.Skip(i).Take(maxBatchSize).ToList(); + batches.Add((chunk, (i / maxBatchSize) + 1)); + } + + Console.WriteLine($"BatchDescribeSObjects: {sObjectNames.Count} objects → {batches.Count} Composite Batch request(s)"); + + var batchEndpoint = $"{_instanceUrl}/services/data/v60.0/composite/batch"; + + // Execute all batches in parallel + var batchTasks = batches.Select(async b => + { + Console.WriteLine($"BatchDescribeSObjects: sending batch {b.BatchNumber}/{batches.Count} ({b.Names.Count} objects)"); + var batchRequest = new SalesforceBatchDescribeRequest + { + BatchRequests = b.Names.Select(name => new SalesforceBatchDescribeSubRequest + { + Method = "GET", + Url = $"/services/data/v60.0/sobjects/{name}/describe/" + }).ToList() + }; + + var jsonContent = new StringContent( + JsonSerializer.Serialize(batchRequest, SalesforceJsonOptions), + System.Text.Encoding.UTF8, + "application/json" + ); + + var batchResults = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var response = await _httpClient.PostAsync(batchEndpoint, jsonContent, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine($"BatchDescribeSObjects batch {b.BatchNumber} failed: {response.StatusCode} - {err}"); + foreach (var name in b.Names) batchResults[name] = null; + return batchResults; + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var batchResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); + + if (batchResponse?.Results != null) + { + for (int i = 0; i < b.Names.Count; i++) + { + var objectName = b.Names[i]; + if (i >= batchResponse.Results.Count) + { + batchResults[objectName] = null; + continue; + } + + var subResponse = batchResponse.Results[i]; + if (subResponse.StatusCode >= 200 && subResponse.StatusCode < 300 && subResponse.Result.HasValue) + { + try + { + batchResults[objectName] = JsonSerializer.Deserialize( + subResponse.Result.Value.GetRawText(), SalesforceJsonOptions); + } + catch (JsonException ex) + { + Console.WriteLine($"BatchDescribeSObjects: failed to parse describe for {objectName}: {ex.Message}"); + batchResults[objectName] = null; + } + } + else + { + Console.WriteLine($"BatchDescribeSObjects: describe for {objectName} returned status {subResponse.StatusCode}"); + batchResults[objectName] = null; + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"BatchDescribeSObjects: exception in batch {b.BatchNumber}: {ex.Message}"); + foreach (var name in b.Names) batchResults[name] = null; + } + + return batchResults; + }); + + var allBatchResults = await Task.WhenAll(batchTasks); + foreach (var batchResult in allBatchResults) + foreach (var kvp in batchResult) + allResults[kvp.Key] = kvp.Value; + + var successCount = allResults.Values.Count(v => v != null); + Console.WriteLine($"BatchDescribeSObjects completed: {successCount}/{sObjectNames.Count} objects described successfully."); + return allResults; + } + /// /// Creates a new SObject in Salesforce. /// @@ -1631,6 +1714,43 @@ namespace DataConnection.REST.Implementations public string? NextRecordsUrl { get; set; } } + // ===== Composite Batch API models (for parallel describe calls) ===== + + private class SalesforceBatchDescribeRequest + { + [JsonPropertyName("batchRequests")] + public List BatchRequests { get; set; } = new(); + } + + private class SalesforceBatchDescribeSubRequest + { + [JsonPropertyName("method")] + public string Method { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + } + + private class SalesforceBatchDescribeResponse + { + [JsonPropertyName("hasErrors")] + public bool HasErrors { get; set; } + + [JsonPropertyName("results")] + public List Results { get; set; } = new(); + } + + private class SalesforceBatchDescribeSubResponse + { + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } + + [JsonPropertyName("result")] + public JsonElement? Result { get; set; } + } + + // ===== Composite API models (for create/update/query operations) ===== + private class SalesforceCompositeRequest { [JsonPropertyName("compositeRequest")] diff --git a/Data_Coupler/BackgroundServices/ScheduledJobService.cs b/Data_Coupler/BackgroundServices/ScheduledJobService.cs index c87eb7a..0068600 100644 --- a/Data_Coupler/BackgroundServices/ScheduledJobService.cs +++ b/Data_Coupler/BackgroundServices/ScheduledJobService.cs @@ -232,7 +232,8 @@ public class ScheduledJobService : BackgroundService var result = await dataTransferService.ExecuteProfileAsync( schedule.Profile, schedule.SourceDatabaseOverride, - schedule.DestinationDatabaseOverride); + schedule.DestinationDatabaseOverride, + schedule.EnableDeletionSync); // Aggiorna lo storico con il risultato executionHistory.EndTime = DateTime.Now; diff --git a/Data_Coupler/Data_Coupler.csproj b/Data_Coupler/Data_Coupler.csproj index 106e539..afd4219 100644 --- a/Data_Coupler/Data_Coupler.csproj +++ b/Data_Coupler/Data_Coupler.csproj @@ -4,6 +4,16 @@ net9.0 enable enable + + v + detailed + + + false + + true + + true @@ -20,6 +30,61 @@ all + + all + runtime; build; native; contentfiles; analyzers + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + $(GitLatestTag.Substring(1)) + $(GitLatestTag) + 1.0.0-dev + $(GitCommitSha) + unknown + $(GitBranch) + local + + + + + +{ + "version": "$(ActualVersion)", + "commitSha": "$(ActualCommitSha)", + "branch": "$(ActualBranch)", + "buildDate": "$([System.DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))", + "buildEnvironment": "Development" +} + + + + + + + + + diff --git a/Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs b/Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs index ff77da6..92d8efb 100644 --- a/Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs +++ b/Data_Coupler/Extensions/DataCoupler/DatabaseMethod.cs @@ -67,6 +67,19 @@ public partial class DataCoupler : ComponentBase // ===== METODI DATABASE ===== + /// + /// Verifica se la credenziale database selezionata è di tipo ODBC + /// + /// True se la credenziale è ODBC, altrimenti False + protected bool IsOdbcConnection() + { + if (string.IsNullOrEmpty(selectedDatabaseCredential)) + return false; + + var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential); + return credential?.DatabaseType == DatabaseType.Odbc; + } + /// /// Gestisce il cambio di credenziale database selezionata /// @@ -74,6 +87,12 @@ public partial class DataCoupler : ComponentBase { selectedDatabaseCredential = e.Value?.ToString() ?? ""; ResetDatabaseState(); + + // Se è una connessione ODBC, forza l'uso di query custom + if (IsOdbcConnection()) + { + useCustomQuery = true; + } } /// @@ -571,14 +590,15 @@ public partial class DataCoupler : ComponentBase /// protected async Task ValidateCustomQuery() { - if (string.IsNullOrWhiteSpace(customQuery) || currentDatabaseManager == null) + if (string.IsNullOrWhiteSpace(customQuery)) { isQueryValid = false; - queryValidationMessage = "Query vuota o manager database non disponibile"; + queryValidationMessage = "Query vuota"; return; } isValidatingQuery = true; + IDatabaseManager? tempManager = null; try { @@ -601,13 +621,30 @@ public partial class DataCoupler : ComponentBase return; } + // Per ODBC, crea un database manager temporaneo se non esiste + var managerToUse = currentDatabaseManager; + if (managerToUse == null && IsOdbcConnection()) + { + Logger.LogInformation("Creando database manager temporaneo per validazione query ODBC"); + tempManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDatabaseCredential); + managerToUse = tempManager; + } + + // Se ancora non abbiamo un manager, errore + if (managerToUse == null) + { + isQueryValid = false; + queryValidationMessage = "Manager database non disponibile. Connettersi prima di validare la query."; + return; + } + // Crea una query di test con sintassi appropriata per il tipo di database var testQuery = CreateLimitedQuery(cleanQuery, credential.DatabaseType, 1); Logger.LogInformation("Validando query: {Query}", testQuery); // Prova a eseguire la query per validarla - var testResults = await currentDatabaseManager.ExecuteRawQueryAsync(testQuery); + var testResults = await managerToUse.ExecuteRawQueryAsync(testQuery); if (testResults != null && testResults.Any()) { @@ -623,6 +660,13 @@ public partial class DataCoupler : ComponentBase TryAutoSelectKeyForQuery(queryColumns); Logger.LogInformation("Query validata con successo: {ColumnCount} colonne", queryColumns.Count); + + // Per ODBC, salva il manager se non era già presente + if (IsOdbcConnection() && currentDatabaseManager == null && tempManager != null) + { + currentDatabaseManager = tempManager; + tempManager = null; // Non distruggerlo nel finally + } } else { @@ -639,6 +683,13 @@ public partial class DataCoupler : ComponentBase finally { isValidatingQuery = false; + + // Pulisci il manager temporaneo se non è stato salvato + if (tempManager != null) + { + try { tempManager.Dispose(); } catch { /* Ignora errori di dispose */ } + } + StateHasChanged(); } } diff --git a/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs b/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs index 3c1afe4..83e5f7a 100644 --- a/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs +++ b/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs @@ -140,12 +140,31 @@ public partial class DataCoupler : ComponentBase Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType); - // Discovery delle entità disponibili usando il metodo batch ottimizzato - Logger.LogInformation("Iniziando discovery batch delle entità REST..."); - restEntities = await currentRestDiscovery.DiscoverEntitySummariesAsync(); - isRestConnected = true; + // Avvia entrambe le discovery in parallelo: + // - DiscoverEntitySummariesAsync è veloce (1 API call) → sblocca la UI subito + // - DiscoverEntitiesAsync è pesante (batch describe) → completa in background + Logger.LogInformation("Avvio discovery parallela: entity summaries + entity details (batch)..."); - Logger.LogInformation("Discovery batch completato: trovate {EntityCount} entità REST", restEntities.Count); + var summariesTask = currentRestDiscovery.DiscoverEntitySummariesAsync(); + var entitiesTask = currentRestDiscovery.DiscoverEntitiesAsync(); + + // Attendi le summaries (veloci) e rendi la UI interattiva immediatamente + restEntities = await summariesTask; + isRestConnected = true; + StateHasChanged(); + Logger.LogInformation("Entity summaries completate: {EntityCount} entità. UI interattiva.", restEntities.Count); + + // Attendi i dettagli completi (già in esecuzione in parallelo) + try + { + availableRelationshipObjects = await entitiesTask; + Logger.LogInformation("Entity details (batch) completati: {Count} oggetti disponibili per External ID Relationships.", availableRelationshipObjects.Count); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Impossibile completare il caricamento dei dettagli entità per External ID Relationships"); + availableRelationshipObjects = new List(); + } } catch (Exception ex) { diff --git a/Data_Coupler/Models/VersionInfo.cs b/Data_Coupler/Models/VersionInfo.cs new file mode 100644 index 0000000..42639b2 --- /dev/null +++ b/Data_Coupler/Models/VersionInfo.cs @@ -0,0 +1,53 @@ +namespace Data_Coupler.Models +{ + /// + /// Modello per le informazioni di versione dell'applicazione + /// + public class VersionInfo + { + /// + /// Versione principale (es. "2.1.0") + /// + public string Version { get; set; } = "0.0.0"; + + /// + /// Commit SHA breve (es. "abc1234") + /// + public string CommitSha { get; set; } = "unknown"; + + /// + /// Branch Git (es. "main", "development") + /// + public string Branch { get; set; } = "unknown"; + + /// + /// Data e ora del build + /// + public string BuildDate { get; set; } = "unknown"; + + /// + /// Ambiente di build (es. "Docker", "Local") + /// + public string BuildEnvironment { get; set; } = "Local"; + + /// + /// Restituisce una stringa formattata con la versione completa + /// + public string GetFullVersion() + { + if (CommitSha != "unknown" && Branch != "unknown") + { + return $"v{Version} ({Branch}-{CommitSha})"; + } + return $"v{Version}"; + } + + /// + /// Restituisce una stringa formattata breve per l'UI + /// + public string GetShortVersion() + { + return $"v{Version}"; + } + } +} diff --git a/Data_Coupler/Pages/Counter.razor b/Data_Coupler/Pages/Counter.razor index ef23cb3..8e7e5e4 100644 --- a/Data_Coupler/Pages/Counter.razor +++ b/Data_Coupler/Pages/Counter.razor @@ -11,7 +11,7 @@ @code { private int currentCount = 0; - private void IncrementCount() + private void IncrementCount() { currentCount++; } diff --git a/Data_Coupler/Pages/CredentialManagement.razor b/Data_Coupler/Pages/CredentialManagement.razor index f352df4..bdfbd49 100644 --- a/Data_Coupler/Pages/CredentialManagement.razor +++ b/Data_Coupler/Pages/CredentialManagement.razor @@ -1,10 +1,13 @@ @page "/credentials" +@using System.Linq @using CredentialManager.Models +@using CredentialManager.Services @using DataConnection.CredentialManagement.Interfaces @using DataConnection.CredentialManagement.Models @using Microsoft.AspNetCore.Components.Forms @using Microsoft.JSInterop @inject IDataConnectionCredentialService CredentialService +@inject IOdbcDsnDiscoveryService OdbcDsnDiscoveryService @inject IJSRuntime JSRuntime @inject NavigationManager Navigation @@ -37,7 +40,7 @@
-
-
-
-
- - + @if (currentDatabaseCredential.DatabaseType == CredentialManager.Models.DatabaseType.Odbc) + { + +
+
+
Configurazione ODBC
-
-
-
- - -
-
-
- - -
Se non specificato, la connessione sarà al server senza selezionare un database specifico
-
+
+
+ + + + @if (currentDatabaseCredential.OdbcMode == CredentialManager.Models.OdbcConnectionMode.Dsn) + { + Seleziona un DSN ODBC configurato sul sistema + } + else + { + Crea una connection string personalizzata con guida passo-passo + } + +
-
-
-
- - + @if (currentDatabaseCredential.OdbcMode == CredentialManager.Models.OdbcConnectionMode.Dsn) + { + +
+
+
+ + + @if (!string.IsNullOrEmpty(currentDatabaseCredential.OdbcDsnName)) + { + var selectedDsn = availableOdbcDsn.FirstOrDefault(d => d.Name == currentDatabaseCredential.OdbcDsnName); + if (selectedDsn != null) + { +
+ Driver: @selectedDsn.Driver
+ @if (!string.IsNullOrEmpty(selectedDsn.Description)) + { + Descrizione: @selectedDsn.Description
+ } + Tipo: @(selectedDsn.IsUserDsn ? "DSN Utente" : "DSN di Sistema") +
+ } + } +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ } + else + { + +
+ Costruzione Guidata Connection String
+ Compila i campi per costruire automaticamente la connection string ODBC. +
+ +
+ + + @if (!string.IsNullOrEmpty(selectedOdbcDriver)) + { + + Driver selezionato: @selectedOdbcDriver + + } +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+ + + Aggiungi parametri aggiuntivi alla connection string (es. TrustServerCertificate=yes, Encrypt=no, etc.) + + + @if (currentDatabaseCredential.AdditionalParameters != null && currentDatabaseCredential.AdditionalParameters.Any()) + { + @foreach (var param in currentDatabaseCredential.AdditionalParameters.Where(p => p.Key != "Driver").ToList()) + { +
+ + = + + +
+ } + } + else + { +
+ Nessun parametro personalizzato aggiunto +
+ } +
+ + + @if (!string.IsNullOrEmpty(selectedOdbcDriver) || + !string.IsNullOrEmpty(currentDatabaseCredential.Host)) + { +
+ + + + Questa è un'anteprima della connection string che verrà generata + +
+ } + }
-
-
- - + } + else + { + +
+
+
+ + + @if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) + { +
+ SQL Server locale:
+ • Named Instance: localhost\SQLEXPRESS o .\SQLEXPRESS
+ • LocalDB: (localdb)\MSSQLLocalDB
+ • Default: localhost o . (usa porta 1433) +
+ } +
+
+
+
+ + + @if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) + { +
+ Ignorata per named instances e LocalDB +
+ } +
-
+ +
+ + +
Se non specificato, la connessione sarà al server senza selezionare un database specifico
+
+ +
+
+
+ + + @if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) + { +
+ Per Windows Authentication, scrivi Integrated o lascia vuoto +
+ } +
+
+
+
+ + + @if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) + { +
+ Non richiesta per Windows Authentication +
+ } +
+
+
+ }
@@ -596,6 +854,12 @@ else private RestApiCredential? editingRestApiCredential = null; private DatabaseCredential currentDatabaseCredential = new(); private RestApiCredential currentRestApiCredential = new(); + + // ODBC specific state + private List availableOdbcDsn = new(); + private List availableOdbcDrivers = new(); + private string selectedOdbcDriver = string.Empty; + private bool loadingOdbcData = false; protected override async Task OnInitializedAsync() { await RefreshCredentials(); @@ -626,19 +890,26 @@ else #region Database Credential Methods - private void ShowAddDatabaseModal() + private async Task ShowAddDatabaseModal() { editingDatabaseCredential = null; currentDatabaseCredential = new DatabaseCredential { DatabaseType = CredentialManager.Models.DatabaseType.SqlServer, Port = 1433, - CommandTimeout = 30 + CommandTimeout = 30, + AdditionalParameters = new Dictionary() }; showDatabaseModal = true; + + // Se è ODBC, carica i dati automaticamente + if (currentDatabaseCredential.DatabaseType == DatabaseType.Odbc) + { + await LoadOdbcData(); + } } - private void EditDatabaseCredential(DatabaseCredential credential) + private async Task EditDatabaseCredential(DatabaseCredential credential) { editingDatabaseCredential = credential; currentDatabaseCredential = new DatabaseCredential @@ -651,8 +922,24 @@ else Username = credential.Username, Password = credential.Password, CommandTimeout = credential.CommandTimeout, - IgnoreSslErrors = credential.IgnoreSslErrors + IgnoreSslErrors = credential.IgnoreSslErrors, + OdbcDsnName = credential.OdbcDsnName, + OdbcMode = credential.OdbcMode, + AdditionalParameters = credential.AdditionalParameters != null + ? new Dictionary(credential.AdditionalParameters) + : new Dictionary() }; + + // Se è ODBC, carica i dati e ripristina il driver selezionato + if (currentDatabaseCredential.DatabaseType == DatabaseType.Odbc) + { + await LoadOdbcData(); + if (currentDatabaseCredential.AdditionalParameters?.ContainsKey("Driver") == true) + { + selectedOdbcDriver = currentDatabaseCredential.AdditionalParameters["Driver"]; + } + } + showDatabaseModal = true; } @@ -697,16 +984,68 @@ else testingConnection = true; try { - // Valida i campi obbligatori - if (string.IsNullOrEmpty(currentDatabaseCredential.Name) || - string.IsNullOrEmpty(currentDatabaseCredential.Host) || - string.IsNullOrEmpty(currentDatabaseCredential.Username) || - string.IsNullOrEmpty(currentDatabaseCredential.Password)) + // Validazione base: Nome sempre obbligatorio + if (string.IsNullOrEmpty(currentDatabaseCredential.Name)) { - await JSRuntime.InvokeVoidAsync("alert", "Compila tutti i campi obbligatori prima di testare la connessione."); + await JSRuntime.InvokeVoidAsync("alert", "Il nome della credenziale è obbligatorio."); return; } + // Validazione specifica per tipo database + if (currentDatabaseCredential.DatabaseType == DatabaseType.Odbc) + { + // ODBC: Validazione in base alla modalità + if (currentDatabaseCredential.OdbcMode == OdbcConnectionMode.Dsn) + { + // Modalità DSN: richiede DSN selezionato + if (string.IsNullOrEmpty(currentDatabaseCredential.OdbcDsnName)) + { + await JSRuntime.InvokeVoidAsync("alert", "Seleziona un DSN ODBC."); + return; + } + } + else + { + // Modalità Custom: richiede driver e host + if (!currentDatabaseCredential.AdditionalParameters?.ContainsKey("Driver") ?? true) + { + await JSRuntime.InvokeVoidAsync("alert", "Seleziona un driver ODBC."); + return; + } + if (string.IsNullOrEmpty(currentDatabaseCredential.Host)) + { + await JSRuntime.InvokeVoidAsync("alert", "Inserisci il server/host."); + return; + } + } + } + else + { + // Altri database: validazione standard (Host, Username, Password) + // Per SQL Server, permetti Windows Authentication (username vuoto o "Integrated") + bool isSqlServerWithWindowsAuth = currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer && + (string.IsNullOrWhiteSpace(currentDatabaseCredential.Username) || + currentDatabaseCredential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) || + currentDatabaseCredential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(currentDatabaseCredential.Host)) + { + await JSRuntime.InvokeVoidAsync("alert", "Il campo Host è obbligatorio."); + return; + } + + if (!isSqlServerWithWindowsAuth) + { + // Per database che non usano Windows Authentication, richiedi username e password + if (string.IsNullOrEmpty(currentDatabaseCredential.Username) || + string.IsNullOrEmpty(currentDatabaseCredential.Password)) + { + await JSRuntime.InvokeVoidAsync("alert", "Username e Password sono obbligatori. Per SQL Server con Windows Authentication, inserisci 'Integrated' come username."); + return; + } + } + } + var (success, message) = await CredentialService.TestDatabaseConnectionAsync(currentDatabaseCredential); var title = success ? "Test Connessione - Successo" : "Test Connessione - Errore"; @@ -722,6 +1061,212 @@ else } } + #region ODBC Methods + + /// + /// Gestisce il cambio di tipo database per caricare le liste ODBC quando necessario + /// + private async Task OnDatabaseTypeChangedAsync() + { + // Se è ODBC, carica le liste DSN e driver + if (currentDatabaseCredential.DatabaseType == DatabaseType.Odbc) + { + await LoadOdbcData(); + } + + StateHasChanged(); + } + + /// + /// Carica i dati ODBC (DSN e driver disponibili) + /// + private async Task LoadOdbcData() + { + if (loadingOdbcData) return; + + loadingOdbcData = true; + try + { + await Task.Run(() => + { + try + { + availableOdbcDsn = OdbcDsnDiscoveryService.GetAllDsn(); + availableOdbcDrivers = OdbcDsnDiscoveryService.GetInstalledDrivers(); + } + catch (Exception ex) + { + Console.WriteLine($"Errore nel caricamento dati ODBC: {ex.Message}"); + availableOdbcDsn = new List(); + availableOdbcDrivers = new List(); + } + }); + } + finally + { + loadingOdbcData = false; + StateHasChanged(); + } + } + + /// + /// Ricarica manualmente la lista dei DSN ODBC + /// + private async Task RefreshOdbcDsnList() + { + await LoadOdbcData(); + await JSRuntime.InvokeVoidAsync("alert", $"Lista DSN aggiornata: {availableOdbcDsn.Count} DSN trovati"); + } + + /// + /// Ricarica manualmente la lista dei driver ODBC + /// + private async Task RefreshOdbcDriverList() + { + await LoadOdbcData(); + await JSRuntime.InvokeVoidAsync("alert", $"Lista driver aggiornata: {availableOdbcDrivers.Count} driver trovati"); + } + + /// + /// Genera l'anteprima della stringa di connessione ODBC + /// + private string GetOdbcConnectionStringPreview() + { + if (currentDatabaseCredential.DatabaseType != DatabaseType.Odbc) + return string.Empty; + + try + { + // Salva il driver selezionato nei parametri aggiuntivi temporaneamente + if (!string.IsNullOrEmpty(selectedOdbcDriver)) + { + currentDatabaseCredential.AdditionalParameters ??= new Dictionary(); + currentDatabaseCredential.AdditionalParameters["Driver"] = selectedOdbcDriver; + } + + // Usa il metodo di ConnectionStringBuilder per generare la stringa + return ConnectionStringBuilder.BuildConnectionString(currentDatabaseCredential); + } + catch (Exception ex) + { + return $"Errore nella generazione: {ex.Message}"; + } + } + + /// + /// Gestisce la selezione di un DSN dalla lista + /// + private void OnOdbcDsnSelected(ChangeEventArgs e) + { + var dsnName = e.Value?.ToString(); + if (!string.IsNullOrEmpty(dsnName)) + { + currentDatabaseCredential.OdbcDsnName = dsnName; + StateHasChanged(); + } + } + + /// + /// Gestisce il cambio di modalità ODBC (DSN vs Custom) + /// + private void OnOdbcModeChanged(ChangeEventArgs e) + { + if (Enum.TryParse(e.Value?.ToString(), out var mode)) + { + currentDatabaseCredential.OdbcMode = mode; + StateHasChanged(); + } + } + + /// + /// Ottiene i dettagli di un DSN selezionato + /// + private OdbcDsnInfo? GetSelectedDsnDetails() + { + if (string.IsNullOrEmpty(currentDatabaseCredential.OdbcDsnName)) + return null; + + return availableOdbcDsn.FirstOrDefault(dsn => + dsn.Name.Equals(currentDatabaseCredential.OdbcDsnName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Aggiunge un nuovo parametro personalizzato ODBC + /// + private void AddOdbcCustomParameter() + { + currentDatabaseCredential.AdditionalParameters ??= new Dictionary(); + + // Genera un nome univoco per il nuovo parametro + var index = 1; + var paramName = $"Param{index}"; + while (currentDatabaseCredential.AdditionalParameters.ContainsKey(paramName)) + { + index++; + paramName = $"Param{index}"; + } + + currentDatabaseCredential.AdditionalParameters[paramName] = string.Empty; + StateHasChanged(); + } + + /// + /// Aggiorna la chiave di un parametro personalizzato + /// + private void UpdateOdbcParameterKey(string oldKey, string newKey) + { + if (string.IsNullOrWhiteSpace(newKey) || oldKey == newKey) + return; + + if (currentDatabaseCredential.AdditionalParameters == null) + return; + + // Se la nuova chiave esiste già, non fare nulla + if (currentDatabaseCredential.AdditionalParameters.ContainsKey(newKey)) + { + StateHasChanged(); + return; + } + + var value = currentDatabaseCredential.AdditionalParameters[oldKey]; + currentDatabaseCredential.AdditionalParameters.Remove(oldKey); + currentDatabaseCredential.AdditionalParameters[newKey] = value; + StateHasChanged(); + } + + /// + /// Aggiorna il valore di un parametro personalizzato + /// + private void UpdateOdbcParameterValue(string key, string value) + { + if (currentDatabaseCredential.AdditionalParameters == null) + return; + + if (currentDatabaseCredential.AdditionalParameters.ContainsKey(key)) + { + currentDatabaseCredential.AdditionalParameters[key] = value; + StateHasChanged(); + } + } + + /// + /// Rimuove un parametro personalizzato + /// + private void RemoveOdbcParameter(string key) + { + if (currentDatabaseCredential.AdditionalParameters == null) + return; + + // Non permettere la rimozione del parametro Driver + if (key == "Driver") + return; + + currentDatabaseCredential.AdditionalParameters.Remove(key); + StateHasChanged(); + } + + #endregion + #endregion #region REST API Credential Methods diff --git a/Data_Coupler/Pages/DataCoupler.razor b/Data_Coupler/Pages/DataCoupler.razor index 2f78d7d..9d956f8 100644 --- a/Data_Coupler/Pages/DataCoupler.razor +++ b/Data_Coupler/Pages/DataCoupler.razor @@ -70,19 +70,32 @@ @if (!string.IsNullOrEmpty(selectedDatabaseCredential)) { -
- + @if (isDatabaseConnected) { - + Connesso } - Connetti e Scopri Schema - - @if (isDatabaseConnected) - { - Connesso - } -
+
+ } } @if (!string.IsNullOrEmpty(databaseErrorMessage)) { } - - @if (isDatabaseConnected) + + @if (IsOdbcConnection()) + { + +
+
Query SQL Custom:
+ +
+ + +
+ +
+
+ +
+ + + @if (isQueryValid) + { + + + @if (showQueryPreview) + { + + } + } +
+ + @if (!string.IsNullOrEmpty(queryValidationMessage)) + { + @if (isQueryValid) + { + + } + else + { + + } + } + + + @if (showQueryPreview && queryPreviewData.Any()) + { +
+
+
+ Anteprima Risultati Query + @queryPreviewData.Count righe +
+
+
+
+ + + + @if (queryColumns.Any()) + { + @foreach (var col in queryColumns) + { + + } + } + + + + @foreach (var row in queryPreviewData) + { + + @foreach (var col in queryColumns) + { + + } + + } + +
@col
@row.GetValueOrDefault(col)?.ToString()
+
+
+
+ } +
+ } + + + @if (isDatabaseConnected && !IsOdbcConnection()) {
@@ -294,13 +425,56 @@ @if (selectedSourceType == "file") { + + +
- + @if (!string.IsNullOrEmpty(selectedFileName)) { - File selezionato: @selectedFileName + File caricato: @selectedFileName } + + Carica un file per vedere preview e configurare il mapping. Questo file non verrà salvato. + +
+ + +
+ +
+ + +
+ @if (!string.IsNullOrEmpty(uploadedFilePath) && uploadedFilePath == manualFilePath) + { + + File validato e caricato con successo! + + } + + Inserisci il percorso completo del file. Il file deve essere accessibile dal server. +
@if (isProcessingFile) @@ -638,8 +812,11 @@
@{ - var isSourceReady = (selectedSourceType == "database" && isDatabaseConnected && - ((useCustomQuery && isQueryValid) || (!useCustomQuery && !string.IsNullOrEmpty(selectedTable)))) || + // Per ODBC: non richiede isDatabaseConnected, basta query validata + // Per altri database: richiede connessione + (query validata OR tabella selezionata) + var isSourceReady = (selectedSourceType == "database" && + ((IsOdbcConnection() && useCustomQuery && isQueryValid) || + (!IsOdbcConnection() && isDatabaseConnected && ((useCustomQuery && isQueryValid) || (!useCustomQuery && !string.IsNullOrEmpty(selectedTable)))))) || (selectedSourceType == "file" && !string.IsNullOrEmpty(selectedSheet)); } @if (isSourceReady && isRestConnected && selectedRestEntity != null) @@ -743,23 +920,80 @@
- - - + +
+ + +
+ + + @if (!isAddingDefaultValue) + { + + + + } + else + { + +
+ Tipo Valore: + + + + @if (defaultValueType == "datetime") + { + Es: @DateTime.Now.ToString("yyyy-MM-dd") + } + else if (defaultValueType == "boolean") + { + Es: true o false + } + else if (defaultValueType == "decimal") + { + Es: 100.50 + } + +
+ + } +
@@ -788,6 +1022,10 @@ { Mapped } + @if (defaultValues.ContainsKey(property.Name)) + { + Default + }
@@ -797,11 +1035,124 @@
- @if (fieldMappings.Any()) + + @if (selectedRestEntity != null && currentRestDiscovery != null && IsSalesforceClient()) + { +
+
+
+
+ External ID Relationships (Salesforce) +
+
+
+
+ + Relating Records by External ID
+ + Crea relazioni tra oggetti usando ID esterni invece degli ID interni di Salesforce.
+ Esempio: Collega Opportunity ad Account usando Account.CardCode__c = "C60000" +
+
+ + +
+
+ + + Es: Account, Contact +
+ +
+ + + Es: Country__c, CardCode__c +
+ +
+ + + Valore da usare per la relazione +
+ +
+ +
+
+ + + @if (externalIdRelationships.Any()) + { +
+
Relazioni Configurate (@externalIdRelationships.Count)
+
+ + + + + + + + + + + + @foreach (var rel in externalIdRelationships) + { + + + + + + + + } + +
Oggetto CorrelatoExternal ID FieldCampo SorgenteFormato JSON OutputAzioni
@rel.RelatedObjectName@rel.ExternalIdField@rel.SourceField@($"\"{rel.RelationshipName}\": {{ \"{rel.ExternalIdField}\": \"value\" }}") + +
+
+
+ } + else + { +
+ Nessuna relazione External ID configurata. Aggiungine una se necessario. +
+ } +
+
+
+ } + + @if (fieldMappings.Any() || defaultValues.Any()) {
-
Mappature Correnti (@fieldMappings.Count)
+
Configurazione Mapping (@(fieldMappings.Count + defaultValues.Count) totali)
@if (keyFields.Any()) { @@ -809,44 +1160,101 @@ }
-
- - - - - - - - - - - - - @foreach (var mapping in fieldMappings) - { - DbColumnInfo? dbColumn = null; - if (selectedSourceType == "database" && !string.IsNullOrEmpty(selectedTable)) - { - dbColumn = databaseTables.ContainsKey(selectedTable) ? - databaseTables[selectedTable].FirstOrDefault(c => c.Name == mapping.Key) : null; - } - var restProperty = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == mapping.Value); - - - - - - - - - } - -
Campo DatabaseTipo DBProprietà RESTTipo RESTAzioni
@mapping.Key@(dbColumn?.DataType ?? (selectedSourceType == "file" ? "Text" : "Unknown"))@mapping.Value@(restProperty?.Type ?? "Unknown") - -
-
+ + + @if (fieldMappings.Any()) + { +
+
+ Field Mappings (@fieldMappings.Count) +
+
+
+ + + + + + + + + + + + + @foreach (var mapping in fieldMappings) + { + DbColumnInfo? dbColumn = null; + if (selectedSourceType == "database" && !string.IsNullOrEmpty(selectedTable)) + { + dbColumn = databaseTables.ContainsKey(selectedTable) ? + databaseTables[selectedTable].FirstOrDefault(c => c.Name == mapping.Key) : null; + } + var restProperty = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == mapping.Value); + + + + + + + + + } + +
Campo SorgenteTipo SorgenteCampo DestinazioneTipo DestinazioneAzioni
@mapping.Key@(dbColumn?.DataType ?? (selectedSourceType == "file" ? "Text" : "Unknown"))@mapping.Value@(restProperty?.Type ?? "Unknown") + +
+
+
+
+ } + + + @if (defaultValues.Any()) + { +
+
+ Default Values (@defaultValues.Count) +
+
+
+ + + + + + + + + + + + @foreach (var defaultValue in defaultValues) + { + var restProperty = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == defaultValue.Key); + var (value, valueType) = defaultValue.Value; + + + + + + + + } + +
Campo DestinazioneValore DefaultTipo ValoreTipo Campo RESTAzioni
@defaultValue.Key@(value?.ToString() ?? "null") + @valueType + @(restProperty?.Type ?? "Unknown") + +
+
+
+
+ }
} @@ -976,6 +1384,8 @@
} + +
@@ -1016,12 +1426,14 @@ SourceSchema="@GetCurrentDatabaseSchema()" SourceTable="@(useCustomQuery ? "custom_query" : selectedTable)" SourceCustomQuery="@(useCustomQuery ? customQuery : null)" - SourceFilePath="@selectedFileName" + SourceFilePath="@uploadedFilePath" DestinationType="rest" DestinationCredentialId="@(GetCurrentDestinationCredentialIdAsync().Result)" DestinationCredentialName="@selectedRestCredential" DestinationEndpoint="@selectedRestEntity?.Name" - FieldMappings="@GetCurrentFieldMappings()" + FieldMappings="@GetCurrentFieldMappings()" + DefaultValues="@defaultValues" + ExternalIdRelationships="@externalIdRelationships" SourceKeyField="@sourceKeyField" UseRecordAssociations="@useRecordAssociations" OnProfileSaved="@OnProfileSaved" /> diff --git a/Data_Coupler/Pages/DataCoupler.razor.cs b/Data_Coupler/Pages/DataCoupler.razor.cs index ff532e7..a94d274 100644 --- a/Data_Coupler/Pages/DataCoupler.razor.cs +++ b/Data_Coupler/Pages/DataCoupler.razor.cs @@ -36,6 +36,8 @@ public partial class DataCoupler : ComponentBase // File handling private string selectedFileName = ""; + private string manualFilePath = ""; // Percorso inserito manualmente dall'utente + private string uploadedFilePath = ""; // Percorso completo del file validato private bool isProcessingFile = false; private string fileErrorMessage = ""; private Dictionary> fileSheets = new(); // SheetName -> Columns @@ -49,9 +51,24 @@ public partial class DataCoupler : ComponentBase (int)Math.Ceiling((double)fileData[sheetName].Count / pageSize) : 0; // Mapping campi - private Dictionary fieldMappings = new(); // DbColumn -> RestProperty + private Dictionary fieldMappings = new(); // DbColumn -> RestProperty (legacy) + private List fieldMappingEntries = new(); // New system: supporta sia mapping che default values + private Dictionary defaultValues = new(); // DestinationField -> (DefaultValue, Type) private HashSet keyFields = new(); // REST properties marked as keys private string selectedDbColumn = ""; + + // UI per configurazione mapping/default value + private bool isAddingDefaultValue = false; // Toggle tra mapping normale e default value + private string defaultValueField = ""; // Campo destinazione per default value + private string defaultValueInput = ""; // Input utente per default value + private string defaultValueType = "string"; // Tipo del default value (string, int, decimal, boolean, datetime) + + // External ID Relationships (Salesforce) + private List externalIdRelationships = new(); + private string selectedRelationshipObject = ""; + private string selectedExternalIdField = ""; + private string selectedRelationshipSourceField = ""; + private List availableRelationshipObjects = new(); // Oggetti disponibili per relazioni // Gestione chiavi sorgente e associazioni private string sourceKeyField = ""; // Campo che identifica univocamente il record sorgente @@ -250,8 +267,10 @@ public partial class DataCoupler : ComponentBase // Per i file, non possiamo ricreare il file caricato, ma possiamo impostare le informazioni if (!string.IsNullOrEmpty(profile.SourceFilePath)) { + uploadedFilePath = profile.SourceFilePath; selectedFileName = Path.GetFileName(profile.SourceFilePath); - Logger.LogInformation("Informazioni file impostate: {FileName}", selectedFileName); + Logger.LogInformation("Informazioni file impostate - Nome: {FileName}, Percorso: {FilePath}", + selectedFileName, uploadedFilePath); } } } @@ -334,11 +353,13 @@ public partial class DataCoupler : ComponentBase // Applica i mapping fieldMappings.Clear(); + fieldMappingEntries.Clear(); keyFields.Clear(); foreach (var mapping in mappings) { fieldMappings[mapping.SourceField] = mapping.DestinationField; + fieldMappingEntries.Add(FieldMappingEntry.CreateFieldMapping(mapping.SourceField, mapping.DestinationField)); if (mapping.IsKey) { keyFields.Add(mapping.DestinationField); @@ -359,6 +380,42 @@ public partial class DataCoupler : ComponentBase { Logger.LogInformation("Nessun mapping campi da applicare"); } + + // Step 4.5: Applica default values se disponibili + if (!string.IsNullOrEmpty(profile.DefaultValuesJson)) + { + Logger.LogInformation("Step 4.5 - Applicazione default values..."); + try + { + var deserializedDefaults = System.Text.Json.JsonSerializer.Deserialize>( + profile.DefaultValuesJson, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + + if (deserializedDefaults != null) + { + defaultValues.Clear(); + + foreach (var entry in deserializedDefaults) + { + defaultValues[entry.Key] = (entry.Value.Value, entry.Value.Type); + fieldMappingEntries.Add(FieldMappingEntry.CreateDefaultValue(entry.Key, entry.Value.Value, entry.Value.Type)); + + Logger.LogInformation("Default value applicato: {Field} = {Value} ({Type})", + entry.Key, entry.Value.Value, entry.Value.Type); + } + + Logger.LogInformation("Default values applicati - Totale: {Count}", defaultValues.Count); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Errore nel caricamento dei default values dal profilo"); + } + } + else + { + Logger.LogInformation("Nessun default value da applicare"); + } // Step 5: Applica configurazione chiave sorgente if (!string.IsNullOrEmpty(profile.SourceKeyField)) @@ -370,6 +427,51 @@ public partial class DataCoupler : ComponentBase { Logger.LogInformation("Nessuna chiave sorgente da applicare"); } + + // Step 5.5: Carica External ID Relationships (Salesforce) + if (!string.IsNullOrEmpty(profile.ExternalIdRelationshipsJson)) + { + Logger.LogInformation("Step 5.5 - Caricamento External ID Relationships..."); + try + { + var relationships = System.Text.Json.JsonSerializer.Deserialize>( + profile.ExternalIdRelationshipsJson, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + + if (relationships != null && relationships.Any()) + { + externalIdRelationships.Clear(); + + // Normalizza i RelationshipName in base al tipo di oggetto destinazione + bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false; + + foreach (var rel in relationships) + { + // Normalizza il RelationshipName + string normalizedName = NormalizeRelationshipName(rel.RelatedObjectName, isDestinationCustom); + + if (normalizedName != rel.RelationshipName) + { + Logger.LogInformation("Normalizzato RelationshipName: {Old} → {New} (Destination: {Destination}, IsCustom: {IsCustom})", + rel.RelationshipName, normalizedName, selectedRestEntity?.Name, isDestinationCustom); + rel.RelationshipName = normalizedName; + } + + externalIdRelationships.Add(rel); + } + + Logger.LogInformation("External ID Relationships caricate e normalizzate - Totale: {Count}", externalIdRelationships.Count); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Errore nel caricamento delle External ID Relationships dal profilo"); + } + } + else + { + Logger.LogInformation("Nessuna External ID Relationship da applicare"); + } // Step 6: Applica configurazione associazioni record useRecordAssociations = profile.UseRecordAssociations; @@ -401,6 +503,37 @@ public partial class DataCoupler : ComponentBase var profileService = new DataCouplerProfileService(null!); // Usa il service di conversione var profile = profileService.FromDto(profileDto, "System"); // TODO: Usa utente corrente + // Validazione specifica per file CSV + if (profile.SourceType == "file" && !string.IsNullOrEmpty(profile.SourceFilePath)) + { + Logger.LogInformation("Validazione file CSV: {FilePath}", profile.SourceFilePath); + + // Verifica che il file esista + if (!System.IO.File.Exists(profile.SourceFilePath)) + { + await JSRuntime.InvokeVoidAsync("alert", + $"Errore: Il file '{profile.SourceFilePath}' non esiste o non è accessibile. " + + "Verifica il percorso del file prima di salvare il profilo."); + return; + } + + // Verifica che il file sia leggibile + try + { + using var fs = new System.IO.FileStream(profile.SourceFilePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); + fs.Close(); + Logger.LogInformation("File CSV validato con successo: {FilePath}", profile.SourceFilePath); + } + catch (Exception fileEx) + { + Logger.LogError(fileEx, "Errore nella lettura del file CSV: {FilePath}", profile.SourceFilePath); + await JSRuntime.InvokeVoidAsync("alert", + $"Errore: Il file '{profile.SourceFilePath}' non può essere letto. " + + $"Dettagli: {fileEx.Message}"); + return; + } + } + // Controlla se esiste già un profilo con lo stesso nome (inclusi quelli inattivi) Logger.LogInformation("Controllo esistenza profilo con nome: {ProfileName}", profileDto.Name); var existingProfile = await ProfileService.GetProfileByNameIncludingInactiveAsync(profileDto.Name); @@ -431,6 +564,8 @@ public partial class DataCoupler : ComponentBase existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.FieldMappingJson = profile.FieldMappingJson; + existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson; + existingProfile.DefaultValuesJson = profile.DefaultValuesJson; existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.IsActive = true; @@ -464,6 +599,8 @@ public partial class DataCoupler : ComponentBase existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.FieldMappingJson = profile.FieldMappingJson; + existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson; + existingProfile.DefaultValuesJson = profile.DefaultValuesJson; existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.UseRecordAssociations = profile.UseRecordAssociations; @@ -652,7 +789,10 @@ public partial class DataCoupler : ComponentBase ResetSourceState(); ResetDestinationState(); fieldMappings.Clear(); + fieldMappingEntries.Clear(); + defaultValues.Clear(); keyFields.Clear(); + externalIdRelationships.Clear(); // Reset relazioni transferResults.Clear(); transferMessage = ""; } @@ -685,6 +825,8 @@ public partial class DataCoupler : ComponentBase // Reset file state selectedFileName = ""; + manualFilePath = ""; + uploadedFilePath = ""; isProcessingFile = false; fileErrorMessage = ""; fileSheets.Clear(); @@ -698,6 +840,213 @@ public partial class DataCoupler : ComponentBase ClearAllMappings(); } + /// + /// Valida e carica un file dal percorso specificato manualmente + /// + private async Task ValidateAndLoadFileFromPath() + { + try + { + isProcessingFile = true; + fileErrorMessage = ""; + fileSheets.Clear(); + fileData.Clear(); + selectedSheet = ""; + uploadedFilePath = ""; + + if (string.IsNullOrWhiteSpace(manualFilePath)) + { + fileErrorMessage = "Inserire il percorso del file"; + return; + } + + // Valida che il file esista + if (!System.IO.File.Exists(manualFilePath)) + { + fileErrorMessage = $"Il file '{manualFilePath}' non esiste o non è accessibile"; + Logger.LogWarning("File non trovato: {FilePath}", manualFilePath); + return; + } + + // Valida estensione + var extension = Path.GetExtension(manualFilePath).ToLowerInvariant(); + if (extension != ".xlsx" && extension != ".xls" && extension != ".csv") + { + fileErrorMessage = "Formato file non supportato. Utilizzare Excel (.xlsx, .xls) o CSV (.csv)"; + return; + } + + // Verifica che il file sia leggibile + try + { + using var testStream = new System.IO.FileStream(manualFilePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); + testStream.Close(); + } + catch (Exception readEx) + { + fileErrorMessage = $"Il file non può essere letto: {readEx.Message}"; + Logger.LogError(readEx, "Errore nella lettura del file: {FilePath}", manualFilePath); + return; + } + + Logger.LogInformation("Validazione file completata: {FilePath}", manualFilePath); + + // Carica il file dal percorso per preview + selectedFileName = Path.GetFileName(manualFilePath); + + if (extension == ".csv") + { + await ProcessCsvFileFromPath(manualFilePath); + } + else + { + await ProcessExcelFileFromPath(manualFilePath); + } + + // Se tutto è andato bene, salva il percorso validato + uploadedFilePath = manualFilePath; + Logger.LogInformation("File caricato con successo dal percorso: {FilePath}", uploadedFilePath); + } + catch (Exception ex) + { + Logger.LogError(ex, "Errore nella validazione/caricamento del file dal percorso: {FilePath}", manualFilePath); + fileErrorMessage = $"Errore: {ex.Message}"; + uploadedFilePath = ""; + } + finally + { + isProcessingFile = false; + StateHasChanged(); + } + } + + /// + /// Processa un file CSV dal percorso specificato + /// + private async Task ProcessCsvFileFromPath(string filePath) + { + using var stream = new System.IO.FileStream(filePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); + using var reader = new StreamReader(stream); + + var firstLine = await reader.ReadLineAsync(); + if (string.IsNullOrEmpty(firstLine)) + { + fileErrorMessage = "Il file CSV è vuoto"; + return; + } + + var separator = DetectCsvSeparator(firstLine); + var headers = ParseCsvLine(firstLine, separator); + + var sheetName = Path.GetFileNameWithoutExtension(filePath); + fileSheets[sheetName] = headers; + + var dataRows = new List>(); + string? line; + while ((line = await reader.ReadLineAsync()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + var values = ParseCsvLine(line, separator); + var row = new Dictionary(); + for (int i = 0; i < headers.Count; i++) + { + var value = i < values.Count ? values[i] : ""; + row[headers[i]] = string.IsNullOrEmpty(value) ? "" : value; + } + dataRows.Add(row); + } + + fileData[sheetName] = dataRows; + selectedSheet = sheetName; + + Logger.LogInformation("File CSV processato: {FilePath}, Headers: {HeaderCount}, Rows: {RowCount}", + filePath, headers.Count, dataRows.Count); + } + + /// + /// Processa un file Excel dal percorso specificato + /// + private async Task ProcessExcelFileFromPath(string filePath) + { + try + { + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + + using var stream = new System.IO.FileStream(filePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + + IExcelDataReader reader; + if (extension == ".xlsx") + { + reader = ExcelReaderFactory.CreateOpenXmlReader(stream); + } + else if (extension == ".xls") + { + reader = ExcelReaderFactory.CreateBinaryReader(stream); + } + else + { + fileErrorMessage = "Formato Excel non supportato"; + return; + } + + using (reader) + { + var configuration = new ExcelDataSetConfiguration() + { + ConfigureDataTable = (_) => new ExcelDataTableConfiguration() + { + UseHeaderRow = true + } + }; + + var dataSet = reader.AsDataSet(configuration); + + foreach (DataTable table in dataSet.Tables) + { + var sheetName = table.TableName; + var headers = new List(); + var dataRows = new List>(); + + foreach (DataColumn column in table.Columns) + { + headers.Add(column.ColumnName); + } + + foreach (DataRow dataRow in table.Rows) + { + var row = new Dictionary(); + foreach (var header in headers) + { + var value = dataRow[header]; + row[header] = value == DBNull.Value ? "" : value?.ToString() ?? ""; + } + dataRows.Add(row); + } + + fileSheets[sheetName] = headers; + fileData[sheetName] = dataRows; + } + + if (fileSheets.Any()) + { + selectedSheet = fileSheets.First().Key; + } + + Logger.LogInformation("File Excel processato: {FilePath}, Sheets: {SheetCount}", + filePath, dataSet.Tables.Count); + } + + await Task.CompletedTask; + } + catch (Exception ex) + { + Logger.LogError(ex, "Errore nel processing del file Excel: {FilePath}", filePath); + throw; + } + } + private async Task OnFileSelected(InputFileChangeEventArgs e) { try @@ -719,7 +1068,9 @@ public partial class DataCoupler : ComponentBase return; } - // Process file based on type + Logger.LogInformation("File caricato per preview: {FileName}", file.Name); + + // Process file based on type (solo per preview, non salva sul server) if (extension == ".csv") { await ProcessCsvFile(file); @@ -1047,6 +1398,17 @@ public partial class DataCoupler : ComponentBase // Crea il nuovo mapping fieldMappings[selectedDbColumn] = selectedRestProperty; + // Aggiorna anche la lista FieldMappingEntries + var existingEntry = fieldMappingEntries.FirstOrDefault(e => + e.Type == CredentialManager.Models.MappingType.FieldMapping && e.SourceField == selectedDbColumn); + + if (existingEntry != null) + { + fieldMappingEntries.Remove(existingEntry); + } + + fieldMappingEntries.Add(FieldMappingEntry.CreateFieldMapping(selectedDbColumn, selectedRestProperty)); + Logger.LogInformation("Creato mapping: {DbColumn} -> {RestProperty}", selectedDbColumn, selectedRestProperty); // Deseleziona i campi @@ -1054,14 +1416,108 @@ public partial class DataCoupler : ComponentBase selectedRestProperty = ""; } + private void CreateDefaultValue() + { + if (string.IsNullOrEmpty(selectedRestProperty) || string.IsNullOrEmpty(defaultValueInput)) + return; + + try + { + // Converti il valore nel tipo appropriato + object? convertedValue = ConvertDefaultValue(defaultValueInput, defaultValueType); + + // Rimuovi eventuale default value esistente per questo campo + if (defaultValues.ContainsKey(selectedRestProperty)) + { + defaultValues.Remove(selectedRestProperty); + } + + // Rimuovi anche dalla lista entries + var existingEntry = fieldMappingEntries.FirstOrDefault(e => + e.Type == CredentialManager.Models.MappingType.DefaultValue && e.DestinationField == selectedRestProperty); + + if (existingEntry != null) + { + fieldMappingEntries.Remove(existingEntry); + } + + // Aggiungi il nuovo default value + defaultValues[selectedRestProperty] = (convertedValue, defaultValueType); + fieldMappingEntries.Add(FieldMappingEntry.CreateDefaultValue(selectedRestProperty, convertedValue, defaultValueType)); + + Logger.LogInformation("Creato default value: {RestProperty} = {Value} ({Type})", + selectedRestProperty, convertedValue, defaultValueType); + + // Reset campi + selectedRestProperty = ""; + defaultValueInput = ""; + isAddingDefaultValue = false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Errore nella conversione del valore di default"); + transferMessage = $"Errore: {ex.Message}"; + transferMessageType = "error"; + } + } + + private object? ConvertDefaultValue(string input, string type) + { + if (string.IsNullOrEmpty(input)) + return null; + + return type.ToLower() switch + { + "string" => input, + "int" => int.Parse(input), + "long" => long.Parse(input), + "decimal" => decimal.Parse(input, System.Globalization.CultureInfo.InvariantCulture), + "double" => double.Parse(input, System.Globalization.CultureInfo.InvariantCulture), + "float" => float.Parse(input, System.Globalization.CultureInfo.InvariantCulture), + "boolean" => bool.Parse(input), + "datetime" => DateTime.Parse(input), + "datetimeoffset" => DateTimeOffset.Parse(input), + _ => input + }; + } + private void RemoveMapping() { if (string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn)) return; fieldMappings.Remove(selectedDbColumn); + + // Rimuovi anche dalla lista entries + var entry = fieldMappingEntries.FirstOrDefault(e => + e.Type == CredentialManager.Models.MappingType.FieldMapping && e.SourceField == selectedDbColumn); + if (entry != null) + { + fieldMappingEntries.Remove(entry); + } + Logger.LogInformation("Rimosso mapping per campo: {DbColumn}", selectedDbColumn); } + + private void RemoveDefaultValue(string destinationField) + { + if (defaultValues.ContainsKey(destinationField)) + { + defaultValues.Remove(destinationField); + + // Rimuovi anche dalla lista entries + var entry = fieldMappingEntries.FirstOrDefault(e => + e.Type == CredentialManager.Models.MappingType.DefaultValue && e.DestinationField == destinationField); + if (entry != null) + { + fieldMappingEntries.Remove(entry); + } + + Logger.LogInformation("Rimosso default value per campo: {Field}", destinationField); + StateHasChanged(); + } + } + private void RemoveSpecificMapping(string dbColumn) { if (fieldMappings.ContainsKey(dbColumn)) @@ -1074,12 +1530,171 @@ public partial class DataCoupler : ComponentBase private void ClearAllMappings() { fieldMappings.Clear(); + fieldMappingEntries.Clear(); + defaultValues.Clear(); selectedDbColumn = ""; selectedRestProperty = ""; sourceKeyField = ""; transferMessage = ""; transferMessageType = ""; - Logger.LogInformation("Tutti i mapping e le configurazioni sono stati cancellati"); + isAddingDefaultValue = false; + defaultValueField = ""; + defaultValueInput = ""; + externalIdRelationships.Clear(); // Pulisce anche le relazioni + Logger.LogInformation("Tutti i mapping, default values e le configurazioni sono stati cancellati"); + } + + // External ID Relationships Methods + + private void OnRelationshipObjectSelected() + { + // Il valore è già impostato tramite @bind, resettiamo solo i campi dipendenti + selectedExternalIdField = ""; // Reset campo External ID quando cambia l'oggetto + selectedRelationshipSourceField = ""; // Reset anche campo sorgente + StateHasChanged(); + } + + private void AddExternalIdRelationship() + { + if (string.IsNullOrEmpty(selectedRelationshipObject) || + string.IsNullOrEmpty(selectedExternalIdField) || + string.IsNullOrEmpty(selectedRelationshipSourceField)) + { + Logger.LogWarning("Impossibile aggiungere relazione: campi mancanti"); + return; + } + + // Trova il nome dell'oggetto correlato + var relatedObject = availableRelationshipObjects.FirstOrDefault(o => o.Name == selectedRelationshipObject); + if (relatedObject == null) + { + Logger.LogWarning("Oggetto correlato non trovato: {ObjectName}", selectedRelationshipObject); + return; + } + + // Determina il nome della relazione usando il metodo helper + bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false; + string relationshipName = NormalizeRelationshipName(selectedRelationshipObject, isDestinationCustom); + + Logger.LogDebug("Creazione relazione - Destinazione: {Destination} (Custom: {IsCustom}), Correlato: {Related}, RelationshipName: {RelationshipName}", + selectedRestEntity?.Name, isDestinationCustom, selectedRelationshipObject, relationshipName); + + + // Crea la relazione + var relationship = new ExternalIdRelationshipDto + { + RelationshipName = relationshipName, + RelatedObjectName = selectedRelationshipObject, + ExternalIdField = selectedExternalIdField, + SourceField = selectedRelationshipSourceField + }; + + // Verifica duplicati + if (externalIdRelationships.Any(r => + r.RelatedObjectName == relationship.RelatedObjectName && + r.ExternalIdField == relationship.ExternalIdField)) + { + Logger.LogWarning("Relazione già esistente per questo oggetto e campo External ID"); + return; + } + + externalIdRelationships.Add(relationship); + + Logger.LogInformation("Aggiunta relazione External ID: {Relationship}.{Field} <- {SourceField}", + relationship.RelationshipName, relationship.ExternalIdField, relationship.SourceField); + + // Reset campi + selectedRelationshipObject = ""; + selectedExternalIdField = ""; + selectedRelationshipSourceField = ""; + + StateHasChanged(); + } + + private void RemoveExternalIdRelationship(ExternalIdRelationshipDto relationship) + { + if (externalIdRelationships.Remove(relationship)) + { + Logger.LogInformation("Rimossa relazione External ID: {Relationship}.{Field}", + relationship.RelationshipName, relationship.ExternalIdField); + StateHasChanged(); + } + } + + /// + /// Normalizza il nome della relazione in base al tipo di oggetto destinazione. + /// Salesforce External ID Relationships: + /// - Se l'oggetto DESTINAZIONE è CUSTOM → usa sempre __r per tutte le relazioni + /// - Se l'oggetto DESTINAZIONE è STANDARD → usa __r solo per oggetti custom correlati + /// + /// Nome dell'oggetto correlato (es. "Account", "Custom_Company__c") + /// True se l'oggetto destinazione è custom + /// Nome normalizzato della relazione (es. "Account__r", "Account", "Custom_Company__r") + private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom) + { + if (isDestinationCustom) + { + // Destinazione CUSTOM: tutte le relazioni usano __r + if (relatedObjectName.EndsWith("__c")) + { + // Oggetto correlato custom: rimuovi __c e aggiungi __r + return relatedObjectName.Replace("__c", "__r"); + } + else + { + // Oggetto correlato standard: aggiungi __r + return relatedObjectName + "__r"; + } + } + else + { + // Destinazione STANDARD: solo oggetti custom correlati usano __r + if (relatedObjectName.EndsWith("__c")) + { + // Oggetto correlato custom: rimuovi __c e aggiungi __r + return relatedObjectName.Replace("__c", "__r"); + } + else + { + // Oggetto correlato standard: usa solo il nome + return relatedObjectName; + } + } + } + + private List GetExternalIdFieldsForSelectedObject() + { + if (string.IsNullOrEmpty(selectedRelationshipObject)) + return new List(); + + var entity = availableRelationshipObjects.FirstOrDefault(e => e.Name == selectedRelationshipObject); + if (entity == null) + return new List(); + + // Filtra i campi che potrebbero essere External ID (tipicamente campo con __c o specifici tipi) + return entity.Properties + .Where(p => p.Name.EndsWith("__c") || p.Name == "Id" || p.Name.Contains("External")) + .Select(p => p.Name) + .OrderBy(p => p) + .ToList(); + } + + private List GetSourceFieldsForRelationship() + { + // Restituisce i campi sorgente disponibili + if (selectedSourceType == "database") + { + if (useCustomQuery && queryColumns.Any()) + return queryColumns.ToList(); + else if (!useCustomQuery && !string.IsNullOrEmpty(selectedTable) && databaseTables.ContainsKey(selectedTable)) + return databaseTables[selectedTable].Select(c => c.Name).ToList(); + } + else if (selectedSourceType == "file" && fileSheets.ContainsKey(selectedSheet)) + { + return fileSheets[selectedSheet].ToList(); + } + + return new List(); } private void AutoMapFields() @@ -1697,11 +2312,26 @@ public partial class DataCoupler : ComponentBase { var restData = new Dictionary(); + // Crea un set con i campi sorgente usati in External ID Relationships + // per escluderli dai mapping normali (verranno gestiti separatamente) + var externalIdSourceFields = externalIdRelationships + .Where(r => !string.IsNullOrWhiteSpace(r.SourceField)) + .Select(r => r.SourceField) + .ToHashSet(); + + // STEP 1: Applica i mapping normali (campo sorgente -> campo destinazione) foreach (var mapping in fieldMappings) { string dbColumn = mapping.Key; string restProperty = mapping.Value; + // Salta il mapping se il campo è usato in un External ID Relationship + if (externalIdSourceFields.Contains(dbColumn)) + { + Logger.LogDebug("Campo {DbColumn} usato in External ID Relationship, escluso da mapping normale", dbColumn); + continue; + } + if (dbRecord.ContainsKey(dbColumn)) { var value = dbRecord[dbColumn]; @@ -1716,9 +2346,61 @@ public partial class DataCoupler : ComponentBase } } - Logger.LogDebug("Record trasformato: {DbColumns} → {RestProperties}", + // STEP 2: Applica i valori di default per i campi NON ancora popolati + foreach (var defaultValue in defaultValues) + { + string destinationField = defaultValue.Key; + var (value, valueType) = defaultValue.Value; + + // Applica il default value solo se il campo non è già stato popolato dal mapping + if (!restData.ContainsKey(destinationField)) + { + if (value != null) + { + restData[destinationField] = value; + Logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})", + destinationField, value, valueType); + } + } + else + { + Logger.LogDebug("Campo {Field} già popolato da mapping, default value ignorato", destinationField); + } + } + + // STEP 3: Aggiungi External ID Relationships (per Salesforce) + if (externalIdRelationships.Any()) + { + foreach (var relationship in externalIdRelationships) + { + if (!string.IsNullOrWhiteSpace(relationship.SourceField) && + dbRecord.ContainsKey(relationship.SourceField)) + { + var sourceValue = dbRecord[relationship.SourceField]; + var transformedValue = TransformValue(sourceValue, relationship.SourceField, relationship.ExternalIdField); + + if (transformedValue != null) + { + // Crea il dizionario annidato per l'External ID Relationship + // Formato: { "Account": { "CardCode__c": "V50000" } } + var externalIdObject = new Dictionary + { + { relationship.ExternalIdField, transformedValue } + }; + + restData[relationship.RelationshipName] = externalIdObject; + + Logger.LogDebug("Aggiunta External ID Relationship: {RelationshipName}.{ExternalIdField} = {Value} (from {SourceField})", + relationship.RelationshipName, relationship.ExternalIdField, transformedValue, relationship.SourceField); + } + } + } + } + + Logger.LogDebug("Record trasformato: {DbColumns} → {RestProperties} (inclusi {DefaultCount} default values)", string.Join(", ", dbRecord.Keys), - string.Join(", ", restData.Keys)); + string.Join(", ", restData.Keys), + defaultValues.Count(dv => restData.ContainsKey(dv.Key))); return restData; } @@ -2231,13 +2913,6 @@ public partial class DataCoupler : ComponentBase } } - // Verifica che non contenga commenti SQL potenzialmente pericolosi - if (upperQuery.Contains("--") || upperQuery.Contains("/*")) - { - Logger.LogWarning("Query rifiutata: contiene commenti SQL non consentiti"); - return false; - } - return true; } diff --git a/Data_Coupler/Pages/Login.razor b/Data_Coupler/Pages/Login.razor index 68dc1c4..9efe644 100644 --- a/Data_Coupler/Pages/Login.razor +++ b/Data_Coupler/Pages/Login.razor @@ -7,7 +7,7 @@
public interface IDataTransferService { - Task ExecuteProfileAsync(DataCouplerProfile profile, string? sourceDatabaseOverride = null, string? destinationDatabaseOverride = null); + Task ExecuteProfileAsync(DataCouplerProfile profile, string? sourceDatabaseOverride = null, string? destinationDatabaseOverride = null, bool enableDeletionSync = false); } public class DataTransferResult @@ -37,7 +37,7 @@ public class DataTransferService : IDataTransferService _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task ExecuteProfileAsync(DataCouplerProfile profile, string? sourceDatabaseOverride = null, string? destinationDatabaseOverride = null) + public async Task ExecuteProfileAsync(DataCouplerProfile profile, string? sourceDatabaseOverride = null, string? destinationDatabaseOverride = null, bool enableDeletionSync = false) { var result = new DataTransferResult { @@ -59,21 +59,11 @@ public class DataTransferService : IDataTransferService return result; } - // Controlla se il profilo ha file come sorgente e blocca l'esecuzione - if (profile.SourceType?.ToLower() == "file") - { - result.IsSuccess = false; - result.ErrorMessage = "I profili con file come sorgente non sono supportati nelle schedulazioni per motivi di sicurezza."; - result.EndTime = DateTime.Now; // Usa l'ora locale per coerenza - _logger.LogWarning("Tentativo di esecuzione di profilo con file come sorgente bloccato: {ProfileName}", profile.Name); - return result; - } - // Applica override del database se specificati var profileToExecute = await ApplyDatabaseOverrides(profile, sourceDatabaseOverride, destinationDatabaseOverride); // Utilizza il servizio esistente per l'esecuzione - var executionResult = await _scheduledExecutionService.ExecuteProfileAsync(profileToExecute.Id); + var executionResult = await _scheduledExecutionService.ExecuteProfileAsync(profileToExecute.Id, enableDeletionSync); result.IsSuccess = executionResult.Success; result.RecordsProcessed = executionResult.RecordsProcessed; @@ -175,7 +165,8 @@ public class DataTransferService : IDataTransferService if (string.IsNullOrEmpty(profile.DestinationType)) return (false, "Tipo destinazione non specificato"); - if (!profile.SourceCredentialId.HasValue) + // Per le sorgenti file, la credenziale non è richiesta + if (profile.SourceType != "file" && !profile.SourceCredentialId.HasValue) return (false, "Credenziale sorgente non specificata"); if (!profile.DestinationCredentialId.HasValue) diff --git a/Data_Coupler/Services/ScheduledProfileExecutionService.cs b/Data_Coupler/Services/ScheduledProfileExecutionService.cs index 2413df4..6fbb4cf 100644 --- a/Data_Coupler/Services/ScheduledProfileExecutionService.cs +++ b/Data_Coupler/Services/ScheduledProfileExecutionService.cs @@ -13,6 +13,7 @@ using System.Text; using System.Text.Json; using Data_Coupler.Models; using Data_Coupler.Services; +using ExcelDataReader; namespace Data_Coupler.Services; @@ -130,9 +131,15 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic var (restClient, restCredential, restEntity) = await SetupDestinationConnectionAsync(profile); // 2. Verifica che le connessioni siano valide - if (sourceManager == null) + // Per i file, sourceManager sarà null (è normale), per database deve essere presente + if (profile.SourceType.ToLower() == "database" && sourceManager == null) { - throw new InvalidOperationException("Impossibile stabilire connessione con la sorgente dati"); + throw new InvalidOperationException("Impossibile stabilire connessione con il database sorgente"); + } + + if (profile.SourceType.ToLower() == "file" && string.IsNullOrEmpty(profile.SourceFilePath)) + { + throw new InvalidOperationException("Percorso file sorgente non specificato nel profilo"); } if (restClient == null || restEntity == null) @@ -157,18 +164,32 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic throw new InvalidOperationException("Nessun mapping dei campi configurato per il profilo"); } + // 4.5. Parse External ID Relationships (Salesforce) + var externalIdRelationships = ParseExternalIdRelationships(profile.ExternalIdRelationshipsJson); + if (externalIdRelationships.Any()) + { + _logger.LogInformation("Caricate {Count} External ID Relationships dal profilo", externalIdRelationships.Count); + } + + // 4.6. Parse Default Values + var defaultValues = ParseDefaultValues(profile.DefaultValuesJson); + if (defaultValues.Any()) + { + _logger.LogInformation("Caricati {Count} default values dal profilo", defaultValues.Count); + } + // 5. Determina se utilizzare Salesforce Composite API bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient; if (useSalesforceComposite) { _logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento"); - return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, enableDeletionSync); + return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync); } else { _logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento"); - return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, enableDeletionSync); + return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync); } } catch (Exception ex) @@ -187,11 +208,18 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic ///
private async Task<(IDatabaseManager? manager, DatabaseCredential? credential)> SetupSourceConnectionAsync(DataCouplerProfile profile) { - if (profile.SourceType.ToLower() != "database") + _logger.LogInformation("SetupSourceConnectionAsync - SourceType: '{SourceType}', SourceCredentialId: {SourceCredentialId}, SourceFilePath: '{SourceFilePath}'", + profile.SourceType, profile.SourceCredentialId, profile.SourceFilePath); + + // Se la sorgente è un file, non servono credenziali database + if (string.IsNullOrEmpty(profile.SourceType) || + profile.SourceType.Equals("file", StringComparison.OrdinalIgnoreCase)) { - return (null, null); // Per i file gestiremo diversamente + _logger.LogInformation("Sorgente tipo file, nessuna connessione database necessaria"); + return (null, null); } + // Per database, la credenziale è obbligatoria if (!profile.SourceCredentialId.HasValue) { throw new InvalidOperationException("Credenziale sorgente non specificata per il database"); @@ -349,6 +377,100 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic return mappings; } + /// + /// Deserializza gli External ID Relationships dal JSON del profilo + /// + private List ParseExternalIdRelationships(string? externalIdRelationshipsJson) + { + var relationships = new List(); + + if (string.IsNullOrEmpty(externalIdRelationshipsJson)) + { + _logger.LogDebug("ExternalIdRelationships JSON è vuoto o null"); + return relationships; + } + + _logger.LogDebug("Parsing ExternalIdRelationships JSON: {Json}", externalIdRelationshipsJson); + + try + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var relationshipsList = JsonSerializer.Deserialize>(externalIdRelationshipsJson, options); + if (relationshipsList != null) + { + relationships = relationshipsList; + _logger.LogInformation("Trovati {Count} External ID Relationships nel JSON", relationships.Count); + + foreach (var rel in relationships) + { + _logger.LogDebug("External ID Relationship: {RelationshipName} - {RelatedObject}.{ExternalIdField} <- {SourceField}", + rel.RelationshipName, rel.RelatedObjectName, rel.ExternalIdField, rel.SourceField); + } + } + else + { + _logger.LogWarning("Deserializzazione ritornato null per ExternalIdRelationships JSON"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nel parsing degli ExternalIdRelationships: {Json}", externalIdRelationshipsJson); + } + + return relationships; + } + + /// + /// Parse del JSON dei default values + /// + private Dictionary ParseDefaultValues(string? defaultValuesJson) + { + var defaultValues = new Dictionary(); + + if (string.IsNullOrEmpty(defaultValuesJson)) + { + _logger.LogDebug("DefaultValues JSON è vuoto o null"); + return defaultValues; + } + + _logger.LogDebug("Parsing DefaultValues JSON: {Json}", defaultValuesJson); + + try + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var deserializedDefaults = JsonSerializer.Deserialize>(defaultValuesJson, options); + if (deserializedDefaults != null) + { + foreach (var entry in deserializedDefaults) + { + defaultValues[entry.Key] = (entry.Value.Value, entry.Value.Type); + _logger.LogDebug("Default value: {Field} = {Value} ({Type})", + entry.Key, entry.Value.Value, entry.Value.Type); + } + + _logger.LogInformation("Trovati {Count} default values nel JSON", defaultValues.Count); + } + else + { + _logger.LogWarning("Deserializzazione ritornato null per DefaultValues JSON"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nel parsing dei default values: {Json}", defaultValuesJson); + } + + return defaultValues; + } + /// /// Ottiene tutti i record dal database /// @@ -378,18 +500,233 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic } /// - /// Ottiene tutti i record da file (implementazione future) + /// Ottiene tutti i record da file CSV o Excel /// private async Task>> GetAllRecordsFromFileAsync(DataCouplerProfile profile) { if (string.IsNullOrEmpty(profile.SourceFilePath)) throw new InvalidOperationException("Percorso file sorgente non specificato"); - // TODO: Implementazione per file Excel/CSV per le schedulazioni - // Per ora restituiamo una lista vuota - _logger.LogWarning("Lettura file non ancora implementata per le schedulazioni. File: {FilePath}", profile.SourceFilePath); - await Task.Delay(1); // Placeholder async - return new List>(); + if (!System.IO.File.Exists(profile.SourceFilePath)) + throw new FileNotFoundException($"Il file '{profile.SourceFilePath}' non esiste"); + + var extension = System.IO.Path.GetExtension(profile.SourceFilePath).ToLowerInvariant(); + + _logger.LogInformation("Lettura file sorgente: {FilePath} (Tipo: {Extension})", profile.SourceFilePath, extension); + + if (extension == ".csv") + { + return await ReadCsvFileAsync(profile.SourceFilePath); + } + else if (extension == ".xlsx" || extension == ".xls") + { + return await ReadExcelFileAsync(profile.SourceFilePath); + } + else + { + throw new NotSupportedException($"Formato file non supportato: {extension}. Utilizzare .csv, .xlsx o .xls"); + } + } + + /// + /// Legge un file CSV e restituisce i record come dizionari + /// + private async Task>> ReadCsvFileAsync(string filePath) + { + var dataRows = new List>(); + + try + { + // Apri in modalità condivisa per permettere ad altri processi di accedere al file + using var stream = new System.IO.FileStream(filePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); + using var reader = new System.IO.StreamReader(stream); + + var firstLine = await reader.ReadLineAsync(); + if (string.IsNullOrEmpty(firstLine)) + { + _logger.LogWarning("Il file CSV è vuoto: {FilePath}", filePath); + return dataRows; + } + + // Detect separator automatically + var separator = DetectCsvSeparator(firstLine); + _logger.LogDebug("CSV separator rilevato: '{Separator}'", separator); + + // Parse headers (first row) + var headers = ParseCsvLine(firstLine, separator); + _logger.LogInformation("CSV headers: {Headers} (Totale: {Count})", string.Join(", ", headers), headers.Count); + + // Read data rows + string? line; + int rowNumber = 2; + + while ((line = await reader.ReadLineAsync()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + var values = ParseCsvLine(line, separator); + var row = new Dictionary(); + + for (int i = 0; i < headers.Count; i++) + { + var value = i < values.Count ? values[i] : ""; + row[headers[i]] = string.IsNullOrEmpty(value) ? "" : value; + } + + dataRows.Add(row); + rowNumber++; + } + + _logger.LogInformation("File CSV letto con successo: {FilePath}, Record: {RecordCount}", filePath, dataRows.Count); + return dataRows; + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nella lettura del file CSV: {FilePath}", filePath); + throw; + } + } + + /// + /// Legge un file Excel e restituisce i record come dizionari + /// + private async Task>> ReadExcelFileAsync(string filePath) + { + var dataRows = new List>(); + + try + { + // Registra il provider di encoding per ExcelDataReader + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + + // Apri in modalità condivisa per permettere ad altri processi di accedere al file + using var stream = new System.IO.FileStream(filePath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); + var extension = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + + ExcelDataReader.IExcelDataReader reader; + if (extension == ".xlsx") + { + reader = ExcelDataReader.ExcelReaderFactory.CreateOpenXmlReader(stream); + } + else if (extension == ".xls") + { + reader = ExcelDataReader.ExcelReaderFactory.CreateBinaryReader(stream); + } + else + { + throw new NotSupportedException($"Formato Excel non supportato: {extension}"); + } + + using (reader) + { + var configuration = new ExcelDataReader.ExcelDataSetConfiguration() + { + ConfigureDataTable = (_) => new ExcelDataReader.ExcelDataTableConfiguration() + { + UseHeaderRow = true + } + }; + + var dataSet = reader.AsDataSet(configuration); + _logger.LogInformation("File Excel letto: {FilePath}, Fogli: {SheetCount}", filePath, dataSet.Tables.Count); + + // Legge il primo foglio (o tutti i fogli se necessario) + if (dataSet.Tables.Count > 0) + { + var table = dataSet.Tables[0]; + var headers = new List(); + + foreach (System.Data.DataColumn column in table.Columns) + { + headers.Add(column.ColumnName); + } + + foreach (System.Data.DataRow dataRow in table.Rows) + { + var row = new Dictionary(); + foreach (var header in headers) + { + var value = dataRow[header]; + row[header] = value == DBNull.Value ? "" : value?.ToString() ?? ""; + } + dataRows.Add(row); + } + + _logger.LogInformation("Foglio Excel '{SheetName}' letto con successo: {RecordCount} record", + table.TableName, dataRows.Count); + } + } + + await Task.CompletedTask; // Per mantenere il metodo async + return dataRows; + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nella lettura del file Excel: {FilePath}", filePath); + throw; + } + } + + /// + /// Rileva automaticamente il separatore CSV + /// + private char DetectCsvSeparator(string line) + { + var separators = new[] { ',', ';', '\t', '|' }; + var maxCount = 0; + var detectedSeparator = ','; + + foreach (var sep in separators) + { + var count = line.Count(c => c == sep); + if (count > maxCount) + { + maxCount = count; + detectedSeparator = sep; + } + } + + return detectedSeparator; + } + + /// + /// Parse di una riga CSV gestendo correttamente le virgolette + /// + private List ParseCsvLine(string line, char separator = ',') + { + var result = new List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + + if (c == '"') + { + if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') + { + current.Append('"'); + i++; + } + else + { + inQuotes = !inQuotes; + } + } + else if (c == separator && !inQuotes) + { + result.Add(current.ToString().Trim()); + current.Clear(); + } + else + { + current.Append(c); + } + } + + result.Add(current.ToString().Trim()); + return result; } /// @@ -402,6 +739,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic RestEntitySummary restEntity, RestApiCredential restCredential, Dictionary fieldMappings, + Dictionary defaultValues, + List externalIdRelationships, bool enableDeletionSync = false) { _logger.LogInformation("Iniziando trasferimento dati standard per {RecordCount} record - DeletionSync: {DeletionSync}", @@ -415,8 +754,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic { try { - // 1. Trasforma il record utilizzando i field mappings - var restData = TransformRecordForRest(record, fieldMappings); + // 1. Trasforma il record utilizzando i field mappings, default values e External ID Relationships + var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships); // 2. Gestione associazioni record se abilitata string? entityId = null; @@ -526,6 +865,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic RestEntitySummary restEntity, RestApiCredential restCredential, Dictionary fieldMappings, + Dictionary defaultValues, + List externalIdRelationships, bool enableDeletionSync = false) { _logger.LogInformation("Iniziando trasferimento dati COMPOSITE per {RecordCount} record - DeletionSync: {DeletionSync}", @@ -535,7 +876,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient)) { _logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard"); - return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, enableDeletionSync); + return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync); } try @@ -565,8 +906,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic var record = indexedRecord.Record; var recordNumber = indexedRecord.RecordNumber; - // Trasforma il record in base ai mapping (operazione locale, thread-safe) - var restData = TransformRecordForRest(record, fieldMappings); + // Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe) + var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships); // Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE) var sourceKey = GenerateSourceKey(record, profile.SourceKeyField); @@ -856,12 +1197,33 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic /// /// Trasforma un record sorgente in formato REST utilizzando i field mappings /// - private Dictionary TransformRecordForRest(Dictionary sourceRecord, Dictionary fieldMappings) + private Dictionary TransformRecordForRest( + Dictionary sourceRecord, + Dictionary fieldMappings, + Dictionary defaultValues, + List? externalIdRelationships = null) { var restData = new Dictionary(); + // Costruisce un set dei campi sorgente usati esclusivamente come External ID Relationship: + // questi NON devono essere inviati anche come mapping normale (stessa logica della UI manuale). + var externalIdSourceFields = (externalIdRelationships != null) + ? externalIdRelationships + .Where(r => !string.IsNullOrWhiteSpace(r.SourceField)) + .Select(r => r.SourceField) + .ToHashSet() + : new HashSet(); + + // 1. Applica field mappings (escludendo i campi sorgente usati per External ID Relationships) foreach (var mapping in fieldMappings) { + // Salta il campo se è usato come sorgente in un External ID Relationship + if (externalIdSourceFields.Contains(mapping.Key)) + { + _logger.LogDebug("Campo sorgente '{SourceField}' usato in External ID Relationship, escluso dal mapping normale", mapping.Key); + continue; + } + if (sourceRecord.ContainsKey(mapping.Key)) { var value = sourceRecord[mapping.Key]; @@ -876,6 +1238,50 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic } } + // 2. Applica default values (solo se il campo non è già stato mappato) + foreach (var defaultValue in defaultValues) + { + if (!restData.ContainsKey(defaultValue.Key)) + { + var (value, type) = defaultValue.Value; + if (value != null) + { + restData[defaultValue.Key] = value; + _logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})", + defaultValue.Key, value, type); + } + } + } + + // 3. Aggiungi External ID Relationships (per Salesforce) + if (externalIdRelationships != null && externalIdRelationships.Any()) + { + foreach (var relationship in externalIdRelationships) + { + if (!string.IsNullOrWhiteSpace(relationship.SourceField) && + sourceRecord.ContainsKey(relationship.SourceField)) + { + var sourceValue = sourceRecord[relationship.SourceField]; + var transformedValue = TransformValueForRest(sourceValue); + + if (transformedValue != null) + { + // Crea il dizionario annidato per l'External ID Relationship + // Formato: { "Account__r": { "Country__c": "US" } } + var externalIdObject = new Dictionary + { + { relationship.ExternalIdField, transformedValue } + }; + + restData[relationship.RelationshipName] = externalIdObject; + + _logger.LogDebug("Aggiunta External ID Relationship: {RelationshipName} → {ExternalIdField} = {Value}", + relationship.RelationshipName, relationship.ExternalIdField, transformedValue); + } + } + } + } + return restData; } diff --git a/Data_Coupler/Services/VersionService.cs b/Data_Coupler/Services/VersionService.cs new file mode 100644 index 0000000..9a45ce7 --- /dev/null +++ b/Data_Coupler/Services/VersionService.cs @@ -0,0 +1,121 @@ +using Data_Coupler.Models; +using System.Text.Json; + +namespace Data_Coupler.Services +{ + /// + /// Interfaccia per il servizio di gestione versione applicazione + /// + public interface IVersionService + { + /// + /// Ottiene le informazioni sulla versione corrente dell'applicazione + /// + VersionInfo GetVersion(); + + /// + /// Ottiene la versione formattata per display nell'UI + /// + string GetDisplayVersion(); + } + + /// + /// Servizio per gestire le informazioni di versione dell'applicazione + /// Legge i dati da version.json generato durante il build + /// + public class VersionService : IVersionService + { + private readonly VersionInfo _versionInfo; + private readonly ILogger _logger; + private readonly IWebHostEnvironment _env; + + public VersionService(ILogger logger, IWebHostEnvironment env) + { + _logger = logger; + _env = env; + _versionInfo = LoadVersionInfo(); + } + + /// + /// Carica le informazioni di versione dal file version.json + /// + private VersionInfo LoadVersionInfo() + { + try + { + // Cerca il file version.json nella cartella wwwroot o nella root del progetto + string? versionFilePath = null; + + // Prima prova in wwwroot + if (!string.IsNullOrEmpty(_env.WebRootPath)) + { + var wwwrootPath = Path.Combine(_env.WebRootPath, "version.json"); + if (File.Exists(wwwrootPath)) + { + versionFilePath = wwwrootPath; + } + } + + // Se non trovato, prova nella root del progetto + if (versionFilePath == null) + { + var contentPath = Path.Combine(_env.ContentRootPath, "wwwroot", "version.json"); + if (File.Exists(contentPath)) + { + versionFilePath = contentPath; + } + } + + if (versionFilePath != null && File.Exists(versionFilePath)) + { + var json = File.ReadAllText(versionFilePath); + var version = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (version != null) + { + _logger.LogInformation("Version loaded from {Path}: {Version}", versionFilePath, version.GetFullVersion()); + return version; + } + } + else + { + _logger.LogWarning("version.json not found. Searched in WebRootPath: {WebRoot}, ContentRootPath: {ContentRoot}", + _env.WebRootPath ?? "null", _env.ContentRootPath); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading version.json, using default version"); + } + + // Versione di default se il file non esiste o c'è un errore + return new VersionInfo + { + Version = "2.1.0", + CommitSha = "local", + Branch = "dev", + BuildDate = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + BuildEnvironment = "Local" + }; + } + + /// + /// Ottiene le informazioni complete sulla versione + /// + public VersionInfo GetVersion() + { + return _versionInfo; + } + + /// + /// Ottiene la versione formattata per display nell'UI + /// + public string GetDisplayVersion() + { + return _versionInfo.GetShortVersion(); + } + } +} diff --git a/Data_Coupler/Shared/NavMenu.razor b/Data_Coupler/Shared/NavMenu.razor index 223d28f..d7146d2 100644 --- a/Data_Coupler/Shared/NavMenu.razor +++ b/Data_Coupler/Shared/NavMenu.razor @@ -1,6 +1,6 @@