26 Commits

Author SHA1 Message Date
Alessio Dal Santo 11ff67f24d [Feature] Campo nome obbligatorio nella form di creazione connessioni
Aggiunto attributo [Required] al campo Name nelle classi DTO DatabaseCredential
e RestApiCredential in CredentialManager/Models/CredentialModels.cs.

Modifiche:
- Aggiunto `using System.ComponentModel.DataAnnotations` al file
- DatabaseCredential.Name: [Required(ErrorMessage = "Il nome è obbligatorio")]
- RestApiCredential.Name: [Required(ErrorMessage = "Il nome è obbligatorio")]

Il DataAnnotationsValidator già presente nelle EditForm di CredentialManagement.razor
intercetta automaticamente il vincolo e impedisce la submit mostrando il messaggio
di errore inline quando il campo è vuoto, senza modifiche alla logica di salvataggio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:06:18 +02:00
Alessio Dal Santo 0df42e4259 [Cleanup] Rimossa vista FetchData con riferimenti a WeatherForecast 2026-05-28 11:53:08 +02:00
Alessio Dal Santo a81a868005 [Cleanup] Rimozione codice morto
Eliminati file e codice inutilizzati identificati durante l'analisi del codice morto:

File eliminati:
- Data_Coupler/Data/WeatherForecast.cs: classe demo del template Blazor, mai referenziata
- Data_Coupler/Data/WeatherForecastService.cs: servizio demo del template Blazor, mai iniettato
- DataConnection/CredentialManagement/Models/CredentialExtensions2.cs: file vuoto residuo di refactoring
- DataConnection/CredentialManagement/Models/CredentialExtensions_New.cs: file vuoto residuo di refactoring
- DataConnection/CredentialManagement/ServiceCollectionExtensions_New.cs: file vuoto residuo di refactoring
- CredentialManager/Services/KeyMappingService.cs: file vuoto senza implementazione
- DataConnection/DB/EF/ExistingDatabaseExample.cs: file vuoto di esempio non compilato

Interfaccia rimossa:
- DataConnection/CredentialManagement/ServiceCollectionExtensions.cs: rimossa IDataConnectionCredentialServiceConfiguration,
  interfaccia mai implementata né utilizzata in alcuna parte del codebase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:29:10 +02:00
Alessio Dal Santo 344853fde9 [Feature/Perf] Ottimizzazioni bulk pre-discovery, batch deletion sync e supporto OLE DB / Salesforce client_credentials
## Bulk Pre-Discovery e riduzione query SQLite/SOQL

### KeyAssociationService — FindAssociationsByKeyValuesBulkAsync (nuovo)
- Aggiunta query bulk 'WHERE KeyValue IN (...)' per recuperare N associazioni con 1 sola query SQLite
  (chunking a 500 chiavi per rispettare il limite ~999 parametri di SQLite)
- Aggiunta interfaccia IKeyAssociationService e delegata in DataConnectionCredentialService / IDataConnectionCredentialService

### AssociationService — BatchFindOrCreateAssociationsAsync (nuovo)
- Nuovo metodo bulk che sostituisce i loop per-record durante l'analisi composite:
  1) 1 query SQLite bulk per tutte le chiavi
  2) Per le chiavi non trovate: SOQL 'IN (...)' su Salesforce in chunk da 200 via BatchExecuteQueriesAsync
     (ceil(K/25) HTTP Composite call invece di K singole)
  3) Salvataggio parallelo delle associazioni pre-discovery scoperte
- Fallback per-record automatico per client REST non Salesforce
- Aggiornata interfaccia IAssociationService con documentazione XML completa

### DataCoupler.razor.cs — STEP A/B nel flusso COMPOSITE
- Pre-Discovery spostata FUORI dal loop parallelo (STEP A, prima dell'analisi)
- associationsByKey pre-popolato con BatchFindOrCreateAssociationsAsync
- STEP B: il loop parallelo usa TryGetValue O(1) invece di query async per record
- Rimozione blocco ~40 righe di per-record lookup / fallback duplicati

## Salesforce Composite API — Batch Delete e Patch

### SalesforceServiceClient — metodi batch (nuovi)
- BatchDeleteEntitiesAsync: elimina N record con ceil(N/25) Composite call invece di N
- BatchPatchSingleFieldAsync: aggiorna un singolo campo su N record tramite BatchUpdateEntitiesAsync

### DeletionSyncService — refactoring batch
- ExecuteBatchedSalesforceDeletionsAsync: orchestrazione batch per Delete / Deactivate / Mark su Salesforce
- ExecuteSequentialDeletionsAsync: loop sequenziale esistente estratto in metodo riutilizzabile
- Dispatcher: Salesforce -> batch Composite, altri client REST -> sequenziale

## Supporto OLE DB (database)

### DatabaseSchemaProviderFactory
- Aggiunto case DatabaseType.OleDb -> new OleDbSchemaProvider() nel factory switch

### DatabaseMethod.cs
- Aggiunto metodo IsOleDbConnection() (parallelo a IsOdbcConnection())
- Query validation e manager temporaneo estesi a OLE DB oltre che ODBC
- GetLimitedQuery: aggiunto case OleDb -> 'SELECT TOP N FROM (subquery)'

## Salesforce OAuth2 — fix client_credentials

### CredentialService.cs
- Aggiunto 'GrantType' alla HashSet serviceSpecificKeys per preservarlo nella serializzazione AdditionalParameters

### DataConnectionCredentialService.cs
- Refactored BuildRestServiceOptions in helper statico riutilizzato da entrambi i metodi GetRestServiceOptions
- Mapping coerente ClientId/ClientSecret/GrantType per Salesforce (allineato a DataConnectionFactory)
- TestSalesforceOAuthLogin: branch esplicito per client_credentials (no username/password/token)
  con validazione preventiva ClientId+ClientSecret obbligatori
- Log flow label (password|client_credentials) in tutti i messaggi di autenticazione

## VS Code tasks

### .vscode/tasks.json
- Rimosso task generico 'Publish Data_Coupler'
- Aggiunti due task separati: win-x64 e win-x86, entrambi SingleFile + Self-Contained + ReadyToRun
2026-05-28 11:15:18 +02:00
Alessio 82e0d6bc77 [Feature] Aggiunto supporto completo OLE DB per connessione database
## Nuovi file
- DataConnection/DB/OleDbDatabaseManager.cs: Manager completo per connessioni OLE DB
  con supporto Task.Run() per operazioni sincrone, parametri posizionali '?',
  guard per piattaforma Windows, nessun supporto ChangeDatabaseAsync (no-op)
- DataConnection/DB/EF/SchemaProviders/OleDbSchemaProvider.cs: Schema provider per
  OLE DB, usa OleDbSchemaGuid per tabelle/colonne/chiavi primarie, mapping tipi dati
- CredentialManager/Services/OleDbProviderDiscoveryService.cs: Servizio di discovery
  provider OLE DB installati tramite registro Windows (HKEY_CLASSES_ROOT). Rileva
  9 provider noti: VFPOLEDB.1, Microsoft.ACE.OLEDB.12.0, Jet 4.0, SQLOLEDB, ecc.
  Mostra warning per provider solo 32-bit (VFPOLEDB, Jet)
- PUBLISH_32BIT_64BIT.md: Documentazione completa comandi pubblicazione per
  win-x64, win-x86 (richiesto per VFP), linux-x64, osx-x64, osx-arm64.
  Include prerequisiti VFPOLEDB, esempi connection string VFP, note Docker

## File modificati
- DataConnection/DB/Enums/DatabaseType.cs: Aggiunto valore OleDb dopo Odbc
- DataConnection/DataConnection.csproj: Aggiunto pacchetto System.Data.OleDb 9.0.3
- DataConnection/DB/OdbcDatabaseManager.cs: Fix bug ChangeDatabaseAsync con try-catch
- CredentialManager/Models/CredentialModels.cs: Aggiunto OleDb all'enum DatabaseType,
  BuildOleDbConnectionString() con supporto provider da AdditionalParameters,
  default VFPOLEDB.1, costruzione connection string con parametri VFP
- DataConnection/CredentialManagement/Models/CredentialExtensions.cs: Mappatura
  OleDb in ToDataConnectionDatabaseType() e ToCredentialDatabaseType()
- DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs:
  Aggiunto case OleDb in TestDatabaseConnectionAsync e metodo TestOleDbConnection()
  con apertura connessione via Task.Run() e gestione OleDbException dettagliata
- Data_Coupler/Services/DataConnectionFactory.cs: Aggiunto case OleDb per creazione
  OleDbDatabaseManager
- Data_Coupler/Program.cs: Registrazione IOleDbProviderDiscoveryService come Scoped
- Data_Coupler/Pages/CredentialManagement.razor: Aggiunta UI completa OLE DB con
  sezione dedicata Visual FoxPro (percorso .dbc/.dbf, Collating Sequence, DELETED),
  provider discovery con refresh, anteprima connection string, variabili di stato
  e metodi nel codice Blazor, sincronizzazione AdditionalParameters al salvataggio

## Compatibilità
- VFP 8.0/9.0: testato con VFPOLEDB.1, connessione file-based .dbc e .dbf
- Richiede pubblicazione win-x86 per driver OLE DB 32-bit
- AnyCPU non supportato per VFP (COM 32-bit)
2026-05-25 21:20:08 +02:00
Alessio 6452d45b77 [Feature] Salesforce come fonte e Database come destinazione nel Data Coupler
Implementata la possibilita' di usare Salesforce come fonte dati e un database relazionale come destinazione nel flusso di trasferimento dati.

## Modifiche principali

### Nuovi file partial class
- Data_Coupler/Extensions/DataCoupler/SalesforceSourceMethod.cs
  - Stato e metodi per Salesforce come fonte (credenziali, connessione, discovery SObject)
  - ConnectToSalesforceSource(): autenticazione parallela + discovery summaries/details
  - SelectSalesforceSourceEntity(): selezione SObject e caricamento campi
  - GetAllRecordsFromSalesforceSource(): estrazione via ExtractAllEntitiesAsync con solo i campi mappati
  - Filtro/ricerca SObject in tempo reale

- Data_Coupler/Extensions/DataCoupler/DatabaseDestinationMethod.cs
  - Stato e metodi per database come destinazione
  - ConnectToDestinationDatabase(): connessione e discovery tabelle
  - SelectDestinationTable(): caricamento schema tabella on-demand
  - IsDestinationDatabaseReady: proprieta' calcolata per validazione
  - Toggle UI tra destinazione REST e Database

### IDatabaseManager interface
- Aggiunto UpsertRecordAsync(tableName, keyField, keyValue, record):
  - Esegue SELECT COUNT(*) per verificare esistenza record
  - UPDATE se esiste, INSERT se non esiste
  - Implementato in EFCoreDatabaseManager (parametri named @p0..@pN)
  - Implementato in OdbcDatabaseManager (parametri posizionali ?)

### DataCoupler.razor (UI)
- Aggiunto 'Salesforce (REST API)' nel dropdown tipo fonte
- Sezione UI Salesforce fonte: selettore credenziali, bottone connessione, lista SObject con ricerca
- Toggle destinazione REST/Database nella card destra
- Sezione UI Database destinazione: selettore credenziali, bottone connessione, lista tabelle con ricerca
- Colonna destra mapping aggiornata: mostra colonne DB se destinazione e' database, proprieta' REST altrimenti
- Colonna sinistra mapping: aggiunta sezione campi SObject Salesforce
- isSourceReady aggiornato per includere fonte Salesforce
- isDestinationReady aggiornato per includere destinazione database
- Etichette mapping dinamiche in base ai tipi selezionati

### DataCoupler.razor.cs (logica)
- LoadCredentials(): aggiunto caricamento credenziali Salesforce fonte
- ResetSourceState(): aggiunto reset stato Salesforce fonte
- ResetDestinationState(): aggiunto reset stato database destinazione
- GetAllRecordsFromSource(): aggiunto branch Salesforce
- StartDataTransfer(): routing verso StartDataTransferToDatabase() se dest=database
- Aggiunto StartDataTransferToDatabase(): estrae record fonte, applica mapping e default values, chiama UpsertRecordAsync per ogni record
- Rimosso codice duplicato in StartDataTransfer()
2026-05-24 23:55:51 +02:00
Alessio b75e57fe31 [Feature] Aggiunto supporto OAuth2 client_credentials per Salesforce
Implementato il flusso OAuth2 grant_type=client_credentials come alternativa
al flusso password gia' esistente per l'autenticazione Salesforce server-to-server.
La modifica e' completamente retrocompatibile (default rimane Password).

## Dettaglio modifiche

### CredentialManager/Models/CredentialModels.cs
- Aggiunto enum SalesforceGrantType con valori Password e ClientCredentials
- Aggiunta proprieta' GrantType (default: Password) su RestApiCredential
- Aggiunta proprieta' GrantType (default: Password) su SalesforceCredential

### DataConnection/REST/Configuration/RestServiceOptions.cs
- Aggiunta proprieta' SalesforceGrantType per passare il tipo di flusso al client

### DataConnection/REST/Implementations/SalesforceServiceClient.cs
- Iniettato ILogger<SalesforceServiceClient> con NullLogger come fallback
- Sostituiti ~165 Console.WriteLine con chiamate ILogger appropriate
  (LogDebug per dettagli, LogInformation per eventi, LogWarning/LogError per problemi)
- Aggiunto AuthenticateWithPasswordAsync: incapsula il flusso grant_type=password
- Aggiunto AuthenticateWithClientCredentialsAsync: implementa grant_type=client_credentials
  (richiede solo ClientId e ClientSecret, nessun utente, URL My Domain obbligatorio)
- Aggiunto SendTokenRequestAsync: helper condiviso per la POST al token endpoint
- Aggiornato AuthenticateAsync() override: instrada al flusso corretto in base a GrantType
- Rimosso modificatore static da NormalizeNumericValues (usava _logger, causava CS0120)

### Data_Coupler/Services/DataConnectionFactory.cs
- Mappatura del campo GrantType dalle opzioni Salesforce a RestServiceOptions
- Passaggio dell'ILogger al costruttore di SalesforceServiceClient

### CredentialManager/Services/CredentialService.cs
- SaveRestApiCredentialAsync (blocco Salesforce): serializza GrantType in AdditionalParameters
- SaveSalesforceCredentialAsync: aggiunto GrantType nel dizionario iniziale
- MapToRestApiCredential: deserializza GrantType da AdditionalParameters con Enum.TryParse
- MapToSalesforceCredential: idem per il tipo SalesforceCredential

### DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs
- TestSalesforceOAuthLogin aggiornato: per ClientCredentials invia solo client_id e
  client_secret (senza username/password/security_token); per Password comportamento invariato

### Data_Coupler/Pages/CredentialManagement.razor
- Aggiunto dropdown 'Tipo di Autenticazione OAuth2' nella sezione Salesforce
- I campi Username, Password e Security Token vengono nascosti quando si seleziona
  il flusso ClientCredentials
- Alert contestuale: warning My Domain URL per ClientCredentials, info per Password
- GrantType propagato correttamente in EditRestApiCredential e TestRestApiConnectionFromModal

### AGENTS.md
- Aggiunta sezione di documentazione per la nuova funzionalita' OAuth2 client_credentials
2026-05-24 23:11:22 +02:00
Alessio Dal Santo 9fab99112b [Fix] Sicurezza e affidabilità storico esecuzioni schedulazioni
- SchedulingHistory.razor / .cs: iniettato IWebHostEnvironment per nascondere
  lo stack trace (con percorsi di file) in produzione; in produzione viene
  mostrato solo il messaggio di errore sanitizzato e un avviso che invita a
  consultare i log dell'applicazione; in sviluppo il dettaglio completo resta
  visibile invariato.

- Scheduling.razor.cs (ExecuteScheduleManually): isolata la notifica JS
  (ShowSuccessMessage / ShowErrorMessage) in un blocco try-catch separato per
  TaskCanceledException / OperationCanceledException. In questo modo una
  disconnessione del browser durante un'esecuzione lunga non sovrascrive più
  il risultato già salvato correttamente come 'success' con uno stato 'failed'
  e lo stack trace di un'eccezione JSInterop. L'evento viene registrato come
  avviso di log senza impatto sul record storico.

- ScheduledJobService.cs: aggiunto commento esplicativo sul motivo per cui
  il dettaglio completo (ex.ToString) è salvato nel DB ma la UI ne mostra
  solo la versione sanitizzata in produzione.
2026-05-08 13:46:56 +02:00
Alessio Dal Santo 91dbe9ae11 [Feature] Aggiunta protezione machine-binding tramite MachineGuard
- Nuovo progetto MachineGuard: libreria che verifica se la macchina corrente
  è autorizzata all'esecuzione tramite DPAPI (Data Protection API di Windows)
- Nuovo progetto MachineGuardSetup: tool di configurazione da eseguire come
  Amministratore per registrare la macchina autorizzata
- Data_Coupler.sln: aggiunti entrambi i nuovi progetti alla soluzione
- Data_Coupler.csproj: aggiunto riferimento al progetto MachineGuard
- Program.cs: integrazione MachineGuard all'avvio dell'applicazione;
  se la macchina non è autorizzata l'app viene arrestata immediatamente
  con log critico e scrittura nel Windows Event Log
2026-03-30 16:42:43 +02:00
Alessio Dal Santo e43b7dc869 [Fix] Correzione controllo sicurezza query SQL: uso regex con word boundary per evitare falsi positivi su nomi colonna (es. UpdateDate, CreateDate) e gestione corretta dei prefissi sp_/xp_ 2026-03-30 16:40:39 +02:00
Alessio Dal Santo f1f75d59ac [Fix] Dockerfile.windows: aggiunge ContinuousIntegrationBuild=true a dotnet build e publish
Build and Push Docker Images / Build Linux Container (push) Successful in 6m9s
Build and Push Docker Images / Create Multi-Platform Manifest (push) Has been cancelled
Build and Push Docker Images / Build Windows Container (push) Has been cancelled
Senza questo flag il target GenerateVersionJson nel .csproj veniva eseguito
dentro il container Docker Windows dove:
- Non esiste .git (fatal: not a git repository)
- version.json copiato da COPY e' read-only o protetto
→ MSB3491: Access to the path '...\wwwroot\version.json' is denied

Il Dockerfile Linux aveva gia /p:ContinuousIntegrationBuild=true.
La condizione nel .csproj e':
  Condition="'' != 'true' AND '' != 'true'"
quindi con il flag il target viene saltato e version.json usa quello
generato dal workflow prima del docker build.
2026-03-22 16:18:14 +01:00
Alessio Dal Santo 46fc21bf7b [Fix] GitHub Actions: aggiunge generazione version.json prima del Docker build
Il workflow GitHub non generava version.json sul runner prima del build,
quindi Docker copiava il file statico del repository (con versione vecchia 2.1.0).

La Gitea Actions usava gia questo approccio correttamente.

Fix applicato: lo step 'Calcola versione' ora genera anche version.json in
Data_Coupler/wwwroot/version.json per entrambi i job Linux e Windows,
con versione, commit SHA, branch, data build e ambiente (GitHub Actions).

Il VersionService legge version.json all'avvio per display nell'UI.
2026-03-22 16:11:30 +01:00
Alessio Dal Santo e125e758fb [Fix] Refactoring calcolo versione CI/CD: usa git describe invece di dotnet msbuild
Problemi risolti:
- GitHub Windows: errore PowerShell 'Missing closing )' causato da (cd Dir; cmd)
  sintassi bash non valida in PowerShell
- GitHub Linux: versione 1.0.0 invece di 2.3.2 perche il tag v2.3.2 esiste solo
  su Gitea e non su GitHub, quindi MinVer trovava il vecchio tag v1.0.0

Soluzione:
- Sostituito dotnet msbuild -getProperty:Version con git describe --tags --abbrev=0
  che e lo strumento nativo Git per ottenere l'ultimo tag raggiungibile
- Funziona identicamente su Linux (bash) e Windows (PowerShell)
- Non richiede dotnet installato ne accesso al .git dentro Docker
- Rimosso il dotnet build intermedio sul runner (non piu necessario)
- Corretti i percorsi version.json: ora usa Data_Coupler/wwwroot/version.json
  dal root del repo invece di wwwroot/ relativo dopo cd
2026-03-22 15:49:42 +01:00
Alessio Dal Santo c15e6c9065 [Fix] Passa versione MinVer come build-arg al Docker per evitare errore MINVER1001
Il problema era che MinVer veniva eseguito dentro il container Docker dove la
directory .git non esiste, causando il warning MINVER1001 e l'uso di 0.0.0-alpha.0
(poi sostituito dal fallback hardcoded 2.1.0).

Soluzione:
- La versione viene calcolata sul runner CI/CD (dove git e' disponibile)
- Esportata come variabile d'ambiente APP_VERSION via GITHUB_ENV
- Passata al Docker build tramite --build-arg APP_VERSION
- Nei Dockerfile aggiunto ARG APP_VERSION e /p:MinVerVersionOverride per imporla
  a MinVer senza che tenti di accedere a git (assente nel container)
- ARG ridichiarato dopo ogni FROM in multi-stage build (comportamento Docker)
2026-03-20 18:25:54 +01:00
Alessio Dal Santo 4262fd6d71 [Fix] Correzione versioning CI/CD: fetch completo storia Git per MinVer
Aggiunto fetch-depth: 0 al checkout in tutti i job dei workflow GitHub Actions e Gitea Actions.
Rimosso --depth 1 dal clone manuale del job Windows in Gitea.
MinVer necessita della storia completa per risalire ai tag Git e calcolare la versione corretta.
Senza questa correzione la versione risultava sempre 2.1.0 (fallback hardcoded).
Aggiornato anche il valore di fallback da 2.1.0 a 2.3.2.
2026-03-20 18:07:34 +01:00
Alessio Dal Santo 335d587c89 [Feature] Salesforce: batch describe metadati, discovery parallela e fix scheduler External ID
Build and Push Docker Images / Build Linux Container (push) Successful in 6m56s
Build and Push Docker Images / Build Windows Container (push) Has been cancelled
Build and Push Docker Images / Create Multi-Platform Manifest (push) Has been cancelled
- Salesforce Composite Batch API per describe SObject: le describe sono ora
  raggruppate in chunk da 25 e inviate come singole POST a /composite/batch,
  riducendo le chiamate API da N a ceil(N/25); per 200 SObject: da 201 a 9 chiamate.

- Discovery entita' REST in parallelo: DiscoverEntitySummariesAsync e
  DiscoverEntitiesAsync avviate simultaneamente; la lista entita' diventa
  interattiva subito dopo le summaries, i dettagli completano in background
  con StateHasChanged() per aggiornare l'UI istantaneamente.

- Fix scheduler - preservazione ExternalIdRelationshipsJson e DefaultValuesJson:
  in DataCoupler.razor.cs entrambi i blocchi di update profilo esistente
  (riattivazione profilo inattivo e sovrascrittura profilo attivo) omettevano
  questi campi nella copia, causandone l'azzeramento silenzioso ad ogni
  re-salvataggio. Ora entrambi i percorsi propagano correttamente i campi JSON.

- Fix scheduler - esclusione campi sorgente External ID dal mapping normale:
  in ScheduledProfileExecutionService.TransformRecordForRest i campi sorgente
  usati nelle External ID Relationships venivano inclusi anche nel loop di
  field mapping standard, generando dati duplicati nell'entita' destinazione.
  Ora il comportamento e' allineato alla UI manuale (TransformRecordToRestEntity).

- Aggiornata documentazione: README.md, AGENTS.md, copilot-instructions.md
2026-02-20 14:59:13 +01:00
Alessio Dal Santo b1f83aa7ab [Fix] Corretto sistema di versioning per container Docker
- Disabilitato target MSBuild GenerateVersionJson durante build CI/CD

- Aggiunta condizione: non esegue se ContinuousIntegrationBuild=true

- Aggiornato Dockerfile per usare /p:ContinuousIntegrationBuild=true

- Previene sovrascrittura del version.json generato dal workflow Gitea

- Risolve problema: container mostra v1.0.0-dev invece della versione da git tag
2026-02-17 14:32:05 +01:00
Alessio Dal Santo 20ca84e4f7 [Fix] Risolto problema SQLite in container Docker Linux
- Cambiato immagine base da Alpine a Debian per migliore compatibilità SQLite

- Aggiunto SQLitePCLRaw.bundle_e_sqlite3 per librerie native cross-platform

- Installato sqlite3 e libsqlite3-dev in Debian

- Risolve definitivamente: 'Error loading shared library libe_sqlite3.so'
2026-02-17 12:34:44 +01:00
Alessio Dal Santo 91704eb944 [Fix] Aggiunta libreria SQLite nativa al container Docker Linux
- Installato sqlite-libs in Alpine per supportare Microsoft.Data.Sqlite

- Aggiunto curl per healthcheck

- Risolve errore: 'Error loading shared library libe_sqlite3.so'
2026-02-17 12:19:17 +01:00
Alessio Dal Santo 3abfed91e1 [Feature] Implementato sistema di generazione automatica version.json
- Aggiunto MSBuild target che genera version.json automaticamente prima di ogni build

- Versione estratta dal tag git più recente (git describe --tags)

- Rimosso version.json dal tracking git (file generato automaticamente)

- Aggiornato .gitignore per escludere version.json

- Il file viene ora rigenerato ad ogni build con versione, commit SHA, branch e timestamp corretti
2026-02-17 11:20:57 +01:00
Alessio Dal Santo 9d146d521e [Fix] Risolto errore NETSDK1047 nella build Docker Windows
- Aggiunto --runtime win-x64 al comando restore

- Specificato -r win-x64 --self-contained false nella publish

- Il restore ora genera project.assets.json per net9.0/win-x64

- Sintassi corretta: --self-contained false invece di /p:SelfContained=false
2026-02-16 16:04:36 +01:00
Alessio Dal Santo 2e25b451c9 [Fix] Risolto errore NETSDK1067 nella build Docker Windows
- Sostituito /p:UseAppHost=false con /p:SelfContained=false in entrambi i Dockerfile

- .NET 9.0 richiede AppHost per applicazioni self-contained

- SelfContained=false è appropriato per container Docker con runtime separato

- Fix applicato sia a Dockerfile (Linux) che Dockerfile.windows
2026-02-16 15:56:02 +01:00
Alessio Dal Santo 201a15de1f Test auto-aggiornamento container 2026-02-16 15:48:40 +01:00
Alessio Dal Santo b9670ae426 [Feature] Implementato sistema di valori default per campi mapping
- Creato modello FieldMappingEntry per gestione unificata di field mapping e default values

- Aggiunta colonna DefaultValuesJson alla tabella DataCouplerProfile (max 4000 caratteri)

- Implementata UI con toggle per selezionare modalità Mapping o Default

- Supporto per 9 tipi di dati: string, int, long, decimal, double, float, boolean, datetime, datetimeoffset

- Aggiornata logica TransformRecordToRestEntity per applicare valori default dopo field mapping

- Implementata serializzazione/deserializzazione DefaultValues in DataCouplerProfileService

- Sistema completo di salvataggio/caricamento valori default nei profili

- Migrazione database AddDefaultValuesJsonToProfile creata e applicata
2026-02-16 14:42:03 +01:00
Alessio 483eb7b407 Fix: Risolto double-mapping negli External ID Relationships per Salesforce
- Implementata funzionalità completa External ID Relationships nell'interfaccia di mapping
- Corretto bug double-mapping: i campi sorgente usati per External ID non vengono più inclusi nei mapping normali
- Risolto errore MALFORMED_ID causato dall'invio duplicato di campi come proprietà dirette e nested objects
- Implementata logica corretta per relationship names: oggetti standard usano il nome diretto, custom objects usano suffisso __r
- Aggiunta UI a 3 colonne (Object, External ID Field, Source Field) per configurazione External ID Relationships
- Migrazione database per supporto External ID Relationships nei profili
- Aggiornato ProfileSaver.razor.cs per salvare/caricare External ID Relationships
- Aggiornato ScheduledProfileExecutionService.cs per gestire External ID nelle esecuzioni schedulate
- Formato JSON output corretto: { 'Account': { 'CardCode__c': 'V50000' } }

Documentazione: EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md
2026-02-15 18:44:15 +01:00
Alessio Dal Santo ed5316fbdf [Fix] Risolti problemi pubblicazione e validazione query ODBC
- Disabilitato trimming per compatibilità con Blazor Server (risolve crash TypeLoadException)
- Configurati PublishSingleFile e ReadyToRun per deployment ottimizzato
- Rimosso controllo eccessivamente restrittivo sui commenti SQL in validazione query
- Ora permessi commenti -- e /* */ nelle query SELECT ODBC
2026-02-13 10:28:47 +01:00
81 changed files with 8234 additions and 837 deletions
+36 -41
View File
@@ -30,34 +30,30 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '9.0.x' dotnet-version: '9.0.x'
- name: Generate version.json with MinVer - name: Calcola versione e genera version.json
run: | run: |
# Fetch all tags for MinVer to work correctly # Calcola versione tramite git describe (non richiede dotnet build)
git fetch --tags --force git fetch --tags --force
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
# Build project to trigger MinVer (calcola versione automaticamente) if [ -z "$LATEST_TAG" ]; then
cd Data_Coupler echo "Warning: Nessun tag Git trovato. Uso fallback."
dotnet build -c Release /p:ContinuousIntegrationBuild=true VERSION="2.3.2"
else
# Extract version calculated by MinVer from build output VERSION="${LATEST_TAG#v}"
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 fi
echo "MinVer calculated version: $VERSION" echo "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
# Create version.json # Genera version.json
cat > wwwroot/version.json <<EOF cat > Data_Coupler/wwwroot/version.json <<EOF
{ {
"version": "${VERSION}", "version": "${VERSION}",
"commitSha": "${GITHUB_SHA:0:7}", "commitSha": "${GITHUB_SHA:0:7}",
@@ -68,8 +64,10 @@ jobs:
EOF EOF
echo "Generated version.json:" echo "Generated version.json:"
cat wwwroot/version.json cat Data_Coupler/wwwroot/version.json
cd ..
# Esporta la versione come variabile d'ambiente per il Docker build
echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"
shell: bash shell: bash
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -136,6 +134,7 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
# Aumenta timeout per registry lenti # Aumenta timeout per registry lenti
build-args: | build-args: |
APP_VERSION=${{ env.APP_VERSION }}
BUILDKIT_STEP_LOG_MAX_SIZE=50000000 BUILDKIT_STEP_LOG_MAX_SIZE=50000000
provenance: false provenance: false
sbom: false sbom: false
@@ -159,7 +158,7 @@ jobs:
steps: steps:
- name: Checkout repository with Git - name: Checkout repository with Git
run: | run: |
git clone --depth 1 --branch ${{ github.ref_name }} https://alessio:%REGISTRY_TOKEN%@gitea.home-nas-ds.org/${{ github.repository }}.git . git clone --branch ${{ github.ref_name }} https://alessio:%REGISTRY_TOKEN%@gitea.home-nas-ds.org/${{ github.repository }}.git .
if not exist Dockerfile.windows ( if not exist Dockerfile.windows (
echo ERROR: Dockerfile.windows not found echo ERROR: Dockerfile.windows not found
exit /b 1 exit /b 1
@@ -175,33 +174,26 @@ jobs:
dotnet --version dotnet --version
shell: pwsh shell: pwsh
- name: Generate version.json with MinVer - name: Calcola versione e genera version.json
run: | run: |
# Fetch all tags for MinVer to work correctly # Calcola versione tramite git describe (non richiede dotnet build)
git fetch --tags --force git fetch --tags --force
$LATEST_TAG = git describe --tags --abbrev=0 2>$null
# Build project to trigger MinVer if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($LATEST_TAG)) {
cd Data_Coupler Write-Host "Warning: Nessun tag Git trovato. Uso fallback."
dotnet build -c Release /p:ContinuousIntegrationBuild=true $VERSION = "2.3.2"
} else {
# Extract version calculated by MinVer $VERSION = $LATEST_TAG -replace '^v', ''
$VERSION = dotnet msbuild -getProperty:Version -p:ContinuousIntegrationBuild=true 2>$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" Write-Host "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
$COMMIT_SHA = "${{ github.sha }}" $COMMIT_SHA = "${{ github.sha }}"
$SHORT_SHA = $COMMIT_SHA.Substring(0, 7) $SHORT_SHA = $COMMIT_SHA.Substring(0, 7)
$BRANCH = "${{ github.ref_name }}" $BRANCH = "${{ github.ref_name }}"
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC") $BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC")
# Create version.json # Genera version.json
$versionJson = @{ $versionJson = @{
version = $VERSION version = $VERSION
commitSha = $SHORT_SHA commitSha = $SHORT_SHA
@@ -210,11 +202,14 @@ jobs:
buildEnvironment = "Gitea Actions" buildEnvironment = "Gitea Actions"
} | ConvertTo-Json } | ConvertTo-Json
$versionJson | Out-File -FilePath "wwwroot\version.json" -Encoding UTF8 $versionJson | Out-File -FilePath "Data_Coupler\wwwroot\version.json" -Encoding UTF8
Write-Host "Generated version.json:" Write-Host "Generated version.json:"
Get-Content "wwwroot\version.json" Get-Content "Data_Coupler\wwwroot\version.json"
cd ..
# Esporta la versione come variabile d'ambiente per il Docker build
"APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "APP_VERSION=$VERSION esportata per Docker build"
shell: pwsh shell: pwsh
- name: Debug - Verify files - name: Debug - Verify files
@@ -265,7 +260,7 @@ jobs:
) )
echo Building Windows Docker image... echo Building Windows Docker image...
docker build -t temp-windows -f Dockerfile.windows . docker build --build-arg APP_VERSION=%APP_VERSION% -t temp-windows -f Dockerfile.windows .
if errorlevel 1 ( if errorlevel 1 (
echo Build failed! echo Build failed!
exit /b 1 exit /b 1
+9 -2
View File
@@ -107,8 +107,11 @@
- **Parallel Processing**: Elaborazione parallela batch multipli - **Parallel Processing**: Elaborazione parallela batch multipli
- **Performance**: 10-25x più veloce per grandi dataset - **Performance**: 10-25x più veloce per grandi dataset
- **Riduzione API Calls**: 60-90% in meno chiamate - **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: #### Metodi Batch Implementati:
- `BatchDescribeSObjectsAsync`: Describe batch SObject tramite Composite API (max 25 per request) — discovery metadati ottimizzata
- `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL - `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL
- `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi - `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi
- `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query) - `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query)
@@ -117,6 +120,10 @@
- `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia - `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia
- `ExtractRecentlyModifiedAsync`: Sincronizzazione incrementale - `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: #### File Chiave:
- `Data_Coupler/Pages/DataCoupler.razor.cs` - `Data_Coupler/Pages/DataCoupler.razor.cs`
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs` - `DataConnection/REST/Implementations/SalesforceServiceClient.cs`
@@ -528,8 +535,8 @@
--- ---
**Versione**: 2.1 **Versione**: 2.2
**Ultimo Aggiornamento**: 2 Febbraio 2026 **Ultimo Aggiornamento**: 20 Febbraio 2026
**Framework**: .NET 9.0 **Framework**: .NET 9.0
**Sviluppatore**: Alessio Dalsanto **Sviluppatore**: Alessio Dalsanto
**Repository**: https://github.com/AlessioDalsi/Data-Coupler **Repository**: https://github.com/AlessioDalsi/Data-Coupler
+75 -1
View File
@@ -31,6 +31,42 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Calcola versione e genera version.json
run: |
git fetch --tags --force
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LATEST_TAG" ]; then
echo "Warning: Nessun tag Git trovato su questo remote. Uso fallback."
VERSION="2.3.2"
else
VERSION="${LATEST_TAG#v}"
fi
echo "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
# Genera version.json
cat > Data_Coupler/wwwroot/version.json <<EOF
{
"version": "${VERSION}",
"commitSha": "${GITHUB_SHA:0:7}",
"branch": "${GITHUB_REF_NAME}",
"buildDate": "$(date -u +"%Y-%m-%d %H:%M:%S UTC")",
"buildEnvironment": "GitHub Actions"
}
EOF
echo "Generated version.json:"
cat Data_Coupler/wwwroot/version.json
echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"
shell: bash
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -75,6 +111,8 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64 platforms: linux/amd64
build-args: |
APP_VERSION=${{ env.APP_VERSION }}
- name: Generate artifact attestation - name: Generate artifact attestation
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -95,6 +133,42 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Calcola versione e genera version.json
run: |
git fetch --tags --force
$LATEST_TAG = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($LATEST_TAG)) {
Write-Host "Warning: Nessun tag Git trovato su questo remote. Uso fallback."
$VERSION = "2.3.2"
} else {
$VERSION = $LATEST_TAG -replace '^v', ''
}
Write-Host "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
$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")
# Genera version.json
$versionJson = @{
version = $VERSION
commitSha = $SHORT_SHA
branch = $BRANCH
buildDate = $BUILD_DATE
buildEnvironment = "GitHub Actions"
} | ConvertTo-Json
$versionJson | Out-File -FilePath "Data_Coupler\wwwroot\version.json" -Encoding UTF8
Write-Host "Generated version.json:"
Get-Content "Data_Coupler\wwwroot\version.json"
"APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
shell: pwsh
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -128,7 +202,7 @@ jobs:
$imageName = "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}".ToLower() $imageName = "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}".ToLower()
# Build with temporary tag # Build with temporary tag
docker build -t "${imageName}:temp-windows" -f Dockerfile.windows . docker build --build-arg "APP_VERSION=$env:APP_VERSION" -t "${imageName}:temp-windows" -f Dockerfile.windows .
# Parse and push all tags # Parse and push all tags
$tags = "${{ steps.meta.outputs.tags }}" -split "`n" $tags = "${{ steps.meta.outputs.tags }}" -split "`n"
+4
View File
@@ -1,6 +1,10 @@
# Created by https://www.toptal.com/developers/gitignore/api/csharp,visualstudiocode,visualstudio # Created by https://www.toptal.com/developers/gitignore/api/csharp,visualstudiocode,visualstudio
# Edit at https://www.toptal.com/developers/gitignore?templates=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 ### ### Csharp ###
## Ignore Visual Studio temporary files, build results, and ## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons. ## files generated by popular Visual Studio add-ons.
+29 -7
View File
@@ -29,17 +29,39 @@
} }
}, },
{ {
"label": "Publish Data_Coupler", "label": "Publish Data_Coupler Temp SingleFile Self-Contained Ready-To-Run win-x64",
"detail": "Publish the Data Coupler 64-bit with a Single File, Self Contained, Ready-To-Run",
"type": "shell", "type": "shell",
"command": "dotnet", "command": "dotnet",
"args": [ "args": [
"publish", "publish",
"--configuration", "Data_Coupler/Data_Coupler.csproj",
"Release", "-c", "Release",
"--output", "-r", "win-x64",
"${workspaceFolder}/publish", "--self-contained", "true",
"--project", "-p:PublishSingleFile=true",
"Data_Coupler/Data_Coupler.csproj" "-p:PublishReadyToRun=true",
"-p:PublishTrimmed=false",
"-o", "C:\\Temp\\Publish\\Data_Coupler"
],
"group": "build",
"problemMatcher": []
},
{
"label": "Publish Data_Coupler Temp SingleFile Self-Contained Ready-To-Run win-x86",
"detail": "Publish the Data Coupler 32-bit with a Single File, Self Contained, Ready-To-Run",
"type": "shell",
"command": "dotnet",
"args": [
"publish",
"Data_Coupler/Data_Coupler.csproj",
"-c", "Release",
"-r", "win-x86",
"--self-contained", "true",
"-p:PublishSingleFile=true",
"-p:PublishReadyToRun=true",
"-p:PublishTrimmed=false",
"-o", "C:\\Temp\\Publish\\Data_Coupler_x86"
], ],
"group": "build", "group": "build",
"problemMatcher": [] "problemMatcher": []
+64 -2
View File
@@ -13,6 +13,68 @@
- **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati - **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati
- **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza - **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce OAuth2 Client Credentials Flow (2026)**
### Supporto `grant_type=client_credentials` per autenticazione server-to-server
**Data Aggiornamento**: 2026
#### **Panoramica**
Aggiunto supporto per il flusso OAuth2 `client_credentials` come alternativa al flusso `password` già esistente.
Completamente retrocompatibile: il default rimane `Password`.
#### **Enum `SalesforceGrantType`** (in `CredentialManager/Models/CredentialModels.cs`)
```csharp
public enum SalesforceGrantType
{
Password, // grant_type=password — richiede Username, Password, SecurityToken (+ClientId/ClientSecret)
ClientCredentials // grant_type=client_credentials — server-to-server, nessun utente
}
```
#### **Differenze tra i flussi**
| Aspetto | `password` | `client_credentials` |
|---|---|---|
| Richiede Username/Password | ✅ Sì | ❌ No |
| Richiede SecurityToken | ✅ Sì (se non Connected App) | ❌ No |
| ClientId / ClientSecret | Opzionale | ✅ Obbligatorio |
| Base URL | login/test.salesforce.com | **My Domain URL** (es. `https://myorg.my.salesforce.com`) |
| Utente Salesforce | Necessario | Integration User (assegnato nella Connected App) |
#### **File modificati**
- `CredentialManager/Models/CredentialModels.cs` — enum `SalesforceGrantType`, proprietà `GrantType` su `RestApiCredential` e `SalesforceCredential`
- `DataConnection/REST/Configuration/RestServiceOptions.cs` — proprietà `SalesforceGrantType`
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs``AuthenticateWithPasswordAsync`, `AuthenticateWithClientCredentialsAsync`, `SendTokenRequestAsync`; `ILogger` iniettato; `NormalizeNumericValues` reso non-static
- `Data_Coupler/Services/DataConnectionFactory.cs` — mapping `GrantType`, logger passato al client
- `CredentialManager/Services/CredentialService.cs``GrantType` serializzato/deserializzato in `AdditionalParameters` JSON
- `DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs``TestSalesforceOAuthLogin` instrada per `GrantType`
- `Data_Coupler/Pages/CredentialManagement.razor` — dropdown "Tipo di Autenticazione OAuth2"; Username/Password/SecurityToken nascosti per `ClientCredentials`; warning My Domain URL
---
## 🚀 **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** ## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Batch Extraction**
### Miglioramenti Significativi alle Performance REST ### Miglioramenti Significativi alle Performance REST
@@ -1151,7 +1213,7 @@ builder.Services.AddScoped<Data_Coupler.Services.IBackupService, Data_Coupler.Se
--- ---
**Versione**: 1.0 **Versione**: 1.1
**Ultimo Aggiornamento**: Settembre 2024 **Ultimo Aggiornamento**: 20 Febbraio 2026
**Framework**: .NET 9.0 **Framework**: .NET 9.0
**Sviluppatore**: Alessio Dalsanto **Sviluppatore**: Alessio Dalsanto
+4
View File
@@ -25,6 +25,8 @@ public partial class ProfileSaver
[Parameter] public string? DestinationTable { get; set; } [Parameter] public string? DestinationTable { get; set; }
[Parameter] public string? DestinationEndpoint { get; set; } [Parameter] public string? DestinationEndpoint { get; set; }
[Parameter] public List<FieldMappingDto>? FieldMappings { get; set; } [Parameter] public List<FieldMappingDto>? FieldMappings { get; set; }
[Parameter] public Dictionary<string, (object? Value, string? Type)>? DefaultValues { get; set; }
[Parameter] public List<ExternalIdRelationshipDto>? ExternalIdRelationships { get; set; }
[Parameter] public string? SourceKeyField { get; set; } [Parameter] public string? SourceKeyField { get; set; }
[Parameter] public bool UseRecordAssociations { get; set; } [Parameter] public bool UseRecordAssociations { get; set; }
[Parameter] public EventCallback<DataCouplerProfileDto> OnProfileSaved { get; set; } [Parameter] public EventCallback<DataCouplerProfileDto> OnProfileSaved { get; set; }
@@ -78,6 +80,8 @@ public partial class ProfileSaver
DestinationTable = DestinationTable, DestinationTable = DestinationTable,
DestinationEndpoint = DestinationEndpoint, DestinationEndpoint = DestinationEndpoint,
FieldMappings = FieldMappings, FieldMappings = FieldMappings,
DefaultValues = DefaultValues,
ExternalIdRelationships = ExternalIdRelationships,
SourceKeyField = SourceKeyField, SourceKeyField = SourceKeyField,
UseRecordAssociations = UseRecordAssociations UseRecordAssociations = UseRecordAssociations
}; };
@@ -0,0 +1,597 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalParameters")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("CommandTimeout")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<string>("ConnectionString")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DatabaseName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DatabaseType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("EncryptedApiKey")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EncryptedAuthToken")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EncryptedPassword")
.HasColumnType("TEXT");
b.Property<string>("Headers")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Host")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IgnoreSslErrors")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("OdbcDsnName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("OdbcMode")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<int?>("Port")
.HasColumnType("INTEGER");
b.Property<string>("RestServiceType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("TimeoutSeconds")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(100);
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DeletionAction")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("DeletionMarkField")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DeletionMarkValue")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<int?>("DestinationCredentialId")
.HasColumnType("INTEGER");
b.Property<string>("DestinationEndpoint")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DestinationSchema")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationTable")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("ExternalIdRelationshipsJson")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("FieldMappingJson")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("SourceCredentialId")
.HasColumnType("INTEGER");
b.Property<string>("SourceCustomQuery")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("SourceDatabaseName")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceFilePath")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SourceKeyField")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceSchema")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceTable")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<bool>("SyncDeletions")
.HasColumnType("INTEGER");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalInfo")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Data_Hash")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("DeletionSynced")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DeletionSyncedAt")
.HasColumnType("TEXT");
b.Property<string>("DestinationEntity")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationKeyField")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsSourceDeleted")
.HasColumnType("INTEGER");
b.Property<string>("KeyValue")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("LastVerifiedAt")
.HasColumnType("TEXT");
b.Property<string>("MappedDestinationField")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("RestCredentialName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("SourceKeyField")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourcesInfo")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DailyTime")
.HasMaxLength(10)
.HasColumnType("TEXT");
b.Property<int?>("DayOfMonth")
.HasColumnType("INTEGER");
b.Property<int?>("DayOfWeek")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DestinationDatabaseOverride")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("EnableDeletionSync")
.HasColumnType("INTEGER");
b.Property<int>("ExecutionCount")
.HasColumnType("INTEGER");
b.Property<string>("IntervalUnit")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<int?>("IntervalValue")
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("LastExecutionMessage")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int?>("LastExecutionRecordCount")
.HasColumnType("INTEGER");
b.Property<string>("LastExecutionStatus")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime?>("LastExecutionTime")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("NextExecutionTime")
.HasColumnType("TEXT");
b.Property<int>("ProfileId")
.HasColumnType("INTEGER");
b.Property<string>("ScheduleType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime?>("ScheduledDateTime")
.HasColumnType("TEXT");
b.Property<string>("SourceDatabaseOverride")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProfileId");
b.ToTable("ProfileSchedules");
});
modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalInfo")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DestinationInfo")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DestinationType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndTime")
.HasColumnType("TEXT");
b.Property<string>("ErrorDetails")
.HasMaxLength(5000)
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ProfileId")
.HasColumnType("INTEGER");
b.Property<string>("ProfileName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("RecordsProcessed")
.HasColumnType("INTEGER");
b.Property<int?>("RecordsWithErrors")
.HasColumnType("INTEGER");
b.Property<int>("ScheduleId")
.HasColumnType("INTEGER");
b.Property<string>("SourceInfo")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SourceType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("StartTime")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TriggerType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CredentialManager.Data.Migrations
{
/// <inheritdoc />
public partial class AddExternalIdRelationships : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExternalIdRelationshipsJson",
table: "DataCouplerProfiles",
type: "TEXT",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExternalIdRelationshipsJson",
table: "DataCouplerProfiles");
}
}
}
@@ -0,0 +1,601 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalParameters")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("CommandTimeout")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<string>("ConnectionString")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DatabaseName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DatabaseType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("EncryptedApiKey")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EncryptedAuthToken")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EncryptedPassword")
.HasColumnType("TEXT");
b.Property<string>("Headers")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Host")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IgnoreSslErrors")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("OdbcDsnName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("OdbcMode")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<int?>("Port")
.HasColumnType("INTEGER");
b.Property<string>("RestServiceType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("TimeoutSeconds")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(100);
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DefaultValuesJson")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("DeletionAction")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("DeletionMarkField")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DeletionMarkValue")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<int?>("DestinationCredentialId")
.HasColumnType("INTEGER");
b.Property<string>("DestinationEndpoint")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DestinationSchema")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationTable")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("ExternalIdRelationshipsJson")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("FieldMappingJson")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("SourceCredentialId")
.HasColumnType("INTEGER");
b.Property<string>("SourceCustomQuery")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("SourceDatabaseName")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceFilePath")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SourceKeyField")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceSchema")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceTable")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<bool>("SyncDeletions")
.HasColumnType("INTEGER");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalInfo")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Data_Hash")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("DeletionSynced")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DeletionSyncedAt")
.HasColumnType("TEXT");
b.Property<string>("DestinationEntity")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("DestinationKeyField")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsSourceDeleted")
.HasColumnType("INTEGER");
b.Property<string>("KeyValue")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("LastVerifiedAt")
.HasColumnType("TEXT");
b.Property<string>("MappedDestinationField")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("RestCredentialName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("SourceKeyField")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SourcesInfo")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DailyTime")
.HasMaxLength(10)
.HasColumnType("TEXT");
b.Property<int?>("DayOfMonth")
.HasColumnType("INTEGER");
b.Property<int?>("DayOfWeek")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DestinationDatabaseOverride")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("EnableDeletionSync")
.HasColumnType("INTEGER");
b.Property<int>("ExecutionCount")
.HasColumnType("INTEGER");
b.Property<string>("IntervalUnit")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<int?>("IntervalValue")
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("LastExecutionMessage")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int?>("LastExecutionRecordCount")
.HasColumnType("INTEGER");
b.Property<string>("LastExecutionStatus")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime?>("LastExecutionTime")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("NextExecutionTime")
.HasColumnType("TEXT");
b.Property<int>("ProfileId")
.HasColumnType("INTEGER");
b.Property<string>("ScheduleType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime?>("ScheduledDateTime")
.HasColumnType("TEXT");
b.Property<string>("SourceDatabaseOverride")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProfileId");
b.ToTable("ProfileSchedules");
});
modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalInfo")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DestinationInfo")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DestinationType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndTime")
.HasColumnType("TEXT");
b.Property<string>("ErrorDetails")
.HasMaxLength(5000)
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ProfileId")
.HasColumnType("INTEGER");
b.Property<string>("ProfileName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("RecordsProcessed")
.HasColumnType("INTEGER");
b.Property<int?>("RecordsWithErrors")
.HasColumnType("INTEGER");
b.Property<int>("ScheduleId")
.HasColumnType("INTEGER");
b.Property<string>("SourceInfo")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SourceType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("StartTime")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TriggerType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CredentialManager.Data.Migrations
{
/// <inheritdoc />
public partial class AddDefaultValuesJsonToProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DefaultValuesJson",
table: "DataCouplerProfiles",
type: "TEXT",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DefaultValuesJson",
table: "DataCouplerProfiles");
}
}
}
@@ -15,7 +15,7 @@ namespace CredentialManager.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b =>
{ {
@@ -146,6 +146,10 @@ namespace CredentialManager.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("DefaultValuesJson")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("DeletionAction") b.Property<string>("DeletionAction")
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -182,6 +186,10 @@ namespace CredentialManager.Migrations
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExternalIdRelationshipsJson")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("FieldMappingJson") b.Property<string>("FieldMappingJson")
.HasMaxLength(4000) .HasMaxLength(4000)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+116 -7
View File
@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace CredentialManager.Models; namespace CredentialManager.Models;
/// <summary> /// <summary>
@@ -22,6 +24,27 @@ public enum RestServiceType
Salesforce Salesforce
} }
/// <summary>
/// Tipo di flusso OAuth2 per Salesforce
/// </summary>
public enum SalesforceGrantType
{
/// <summary>
/// Flusso Username/Password (grant_type=password).
/// Richiede: ClientId, ClientSecret, Username, Password (+SecurityToken se non IP-trusted).
/// URL di login: https://login.salesforce.com o https://test.salesforce.com.
/// </summary>
Password,
/// <summary>
/// Flusso Client Credentials (grant_type=client_credentials) — server-to-server, senza utente.
/// Richiede: ClientId, ClientSecret.
/// URL obbligatorio: My Domain URL (es. https://myorg.my.salesforce.com).
/// La Connected App deve avere "Enable Client Credentials Flow" attivato e un Integration User assegnato.
/// </summary>
ClientCredentials
}
/// <summary> /// <summary>
/// Tipi di database supportati (allineato con DataConnection.Enums.DatabaseType) /// Tipi di database supportati (allineato con DataConnection.Enums.DatabaseType)
/// </summary> /// </summary>
@@ -34,7 +57,8 @@ public enum DatabaseType
Sqlite, Sqlite,
DB2, DB2,
SapHana, SapHana,
Odbc Odbc,
OleDb
} }
/// <summary> /// <summary>
@@ -58,6 +82,7 @@ public enum OdbcConnectionMode
/// </summary> /// </summary>
public class DatabaseCredential public class DatabaseCredential
{ {
[Required(ErrorMessage = "Il nome è obbligatorio")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public DatabaseType DatabaseType { get; set; } public DatabaseType DatabaseType { get; set; }
public string Host { get; set; } = string.Empty; public string Host { get; set; } = string.Empty;
@@ -80,6 +105,7 @@ public class DatabaseCredential
/// </summary> /// </summary>
public class RestApiCredential public class RestApiCredential
{ {
[Required(ErrorMessage = "Il nome è obbligatorio")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public RestServiceType ServiceType { get; set; } = RestServiceType.Generic; public RestServiceType ServiceType { get; set; } = RestServiceType.Generic;
public string BaseUrl { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty;
@@ -106,6 +132,7 @@ public class RestApiCredential
public string? ApiVersion { get; set; } = "59.0"; public string? ApiVersion { get; set; } = "59.0";
public bool IsSandbox { get; set; } = false; public bool IsSandbox { get; set; } = false;
public bool UseSoapApi { get; set; } = false; public bool UseSoapApi { get; set; } = false;
public SalesforceGrantType GrantType { get; set; } = SalesforceGrantType.Password;
public string? RefreshToken { get; set; } public string? RefreshToken { get; set; }
public string? AccessToken { get; set; } public string? AccessToken { get; set; }
public DateTime? TokenExpiry { get; set; } public DateTime? TokenExpiry { get; set; }
@@ -145,6 +172,8 @@ public class SalesforceCredential
public bool IsSandbox { get; set; } = false; // Se è un ambiente sandbox public bool IsSandbox { get; set; } = false; // Se è un ambiente sandbox
public int TimeoutSeconds { get; set; } = 120; public int TimeoutSeconds { get; set; } = 120;
public bool UseSoapApi { get; set; } = false; // Se usare SOAP invece di REST public bool UseSoapApi { get; set; } = false; // Se usare SOAP invece di REST
/// <summary>Tipo di flusso OAuth2 da utilizzare. Default: Password (retrocompatibile).</summary>
public SalesforceGrantType GrantType { get; set; } = SalesforceGrantType.Password;
public string? RefreshToken { get; set; } public string? RefreshToken { get; set; }
public string? AccessToken { get; set; } public string? AccessToken { get; set; }
public DateTime? TokenExpiry { get; set; } public DateTime? TokenExpiry { get; set; }
@@ -170,17 +199,56 @@ public static class ConnectionStringBuilder
DatabaseType.DB2 => BuildDb2ConnectionString(credential), DatabaseType.DB2 => BuildDb2ConnectionString(credential),
DatabaseType.SapHana => BuildSapHanaConnectionString(credential), DatabaseType.SapHana => BuildSapHanaConnectionString(credential),
DatabaseType.Odbc => BuildOdbcConnectionString(credential), DatabaseType.Odbc => BuildOdbcConnectionString(credential),
DatabaseType.OleDb => BuildOleDbConnectionString(credential),
_ => throw new NotSupportedException($"Database type {credential.DatabaseType} not supported") _ => throw new NotSupportedException($"Database type {credential.DatabaseType} not supported")
}; };
} private static string BuildSqlServerConnectionString(DatabaseCredential credential) } private static string BuildSqlServerConnectionString(DatabaseCredential credential)
{ {
var builder = new List<string> var builder = new List<string>();
// 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}", // Per named instances e LocalDB, non includere la porta
$"User Id={credential.Username}", builder.Add($"Server={credential.Host}");
$"Password={credential.Password}", }
$"Connection Timeout={credential.CommandTimeout}" 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 // Aggiungi Database solo se specificato
if (!string.IsNullOrEmpty(credential.DatabaseName)) if (!string.IsNullOrEmpty(credential.DatabaseName))
@@ -365,6 +433,47 @@ public static class ConnectionStringBuilder
return string.Join(";", builder); return string.Join(";", builder);
} }
private static string BuildOleDbConnectionString(DatabaseCredential credential)
{
// Se è già presente una connection string personalizzata, utilizzala
if (!string.IsNullOrEmpty(credential.ConnectionString))
return credential.ConnectionString;
var builder = new List<string>();
// Provider OLE DB (obbligatorio)
var provider = credential.AdditionalParameters?.GetValueOrDefault("Provider") ?? "VFPOLEDB.1";
builder.Add($"Provider={provider}");
// Data Source: per VFP e Access è il percorso file/cartella
// DatabaseName è il campo principale (come per SQLite)
var dataSource = !string.IsNullOrEmpty(credential.DatabaseName)
? credential.DatabaseName
: credential.Host;
if (!string.IsNullOrEmpty(dataSource))
builder.Add($"Data Source={dataSource}");
// Credenziali (opzionali per VFP file-based)
if (!string.IsNullOrEmpty(credential.Username))
builder.Add($"User ID={credential.Username}");
if (!string.IsNullOrEmpty(credential.Password))
builder.Add($"Password={credential.Password}");
// Parametri aggiuntivi specifici (es. Collating Sequence, Exclusive, DELETED per VFP)
if (credential.AdditionalParameters != null)
{
foreach (var param in credential.AdditionalParameters)
{
if (param.Key != "Provider") // Provider già gestito sopra
builder.Add($"{param.Key}={param.Value}");
}
}
return string.Join(";", builder);
}
private static void AddAdditionalParameters(List<string> builder, Dictionary<string, string>? additionalParams) private static void AddAdditionalParameters(List<string> builder, Dictionary<string, string>? additionalParams)
{ {
if (additionalParams != null) if (additionalParams != null)
@@ -60,6 +60,15 @@ public class DataCouplerProfile
[MaxLength(4000)] [MaxLength(4000)]
public string? FieldMappingJson { get; set; } 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 // Configurazione chiave sorgente e associazioni
[MaxLength(200)] [MaxLength(200)]
public string? SourceKeyField { get; set; } public string? SourceKeyField { get; set; }
@@ -30,6 +30,12 @@ public class DataCouplerProfileDto
// Mapping dei campi // Mapping dei campi
public List<FieldMappingDto>? FieldMappings { get; set; } public List<FieldMappingDto>? FieldMappings { get; set; }
// Default values per campi destinazione (FieldName -> (Value, Type))
public Dictionary<string, (object? Value, string? Type)>? DefaultValues { get; set; }
// External ID Relationships per Salesforce
public List<ExternalIdRelationshipDto>? ExternalIdRelationships { get; set; }
// Configurazione chiave sorgente e associazioni // Configurazione chiave sorgente e associazioni
public string? SourceKeyField { get; set; } public string? SourceKeyField { get; set; }
public bool UseRecordAssociations { get; set; } public bool UseRecordAssociations { get; set; }
@@ -47,10 +53,48 @@ public class FieldMappingDto
public bool IsRequired { get; set; } public bool IsRequired { get; set; }
public string? DefaultValue { get; set; } public string? DefaultValue { get; set; }
public string? Transformation { get; set; } public string? Transformation { get; set; }
/// <summary>
/// Lista di relazioni External ID associate a questo campo (per Salesforce)
/// </summary>
public List<ExternalIdRelationshipDto>? ExternalIdRelationships { get; set; }
} }
/// <summary> /// <summary>
/// DTO per la visualizzazione di un profilo nella lista /// DTO per External ID Relationship (Salesforce)
/// </summary>
public class ExternalIdRelationshipDto
{
/// <summary>
/// Nome della relazione (es. "Account__r")
/// </summary>
public string RelationshipName { get; set; } = string.Empty;
/// <summary>
/// Nome dell'oggetto correlato (es. "Account")
/// </summary>
public string RelatedObjectName { get; set; } = string.Empty;
/// <summary>
/// Campo External ID dell'oggetto correlato (es. "Country__c")
/// </summary>
public string ExternalIdField { get; set; } = string.Empty;
/// <summary>
/// Campo sorgente da cui prendere il valore per l'External ID
/// </summary>
public string SourceField { get; set; } = string.Empty;
}
/// <summary>/// DTO per i valori di default
/// </summary>
public class DefaultValueDto
{
public object? Value { get; set; }
public string? Type { get; set; }
}
/// <summary>/// DTO per la visualizzazione di un profilo nella lista
/// </summary> /// </summary>
public class DataCouplerProfileSummaryDto public class DataCouplerProfileSummaryDto
{ {
+174
View File
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace CredentialManager.Models
{
/// <summary>
/// Tipo di mapping field
/// </summary>
public enum MappingType
{
/// <summary>
/// Mapping da campo sorgente a campo destinazione
/// </summary>
FieldMapping,
/// <summary>
/// Valore di default per campo destinazione
/// </summary>
DefaultValue
}
/// <summary>
/// Rappresenta una voce di mapping che può essere:
/// - Un mapping da campo sorgente a campo destinazione
/// - Un valore di default per un campo destinazione
/// </summary>
public class FieldMappingEntry
{
/// <summary>
/// Tipo di mapping
/// </summary>
[JsonPropertyName("type")]
public MappingType Type { get; set; }
/// <summary>
/// Nome del campo sorgente (solo per FieldMapping)
/// </summary>
[JsonPropertyName("sourceField")]
public string? SourceField { get; set; }
/// <summary>
/// Nome del campo destinazione
/// </summary>
[JsonPropertyName("destinationField")]
public string DestinationField { get; set; } = string.Empty;
/// <summary>
/// Valore di default (solo per DefaultValue)
/// </summary>
[JsonPropertyName("defaultValue")]
public object? DefaultValue { get; set; }
/// <summary>
/// Tipo di dato del valore di default (per conversioni corrette)
/// Esempi: "string", "int", "decimal", "boolean", "datetime"
/// </summary>
[JsonPropertyName("defaultValueType")]
public string? DefaultValueType { get; set; }
/// <summary>
/// Crea un mapping da campo sorgente a campo destinazione
/// </summary>
public static FieldMappingEntry CreateFieldMapping(string sourceField, string destinationField)
{
return new FieldMappingEntry
{
Type = MappingType.FieldMapping,
SourceField = sourceField,
DestinationField = destinationField
};
}
/// <summary>
/// Crea un valore di default per un campo destinazione
/// </summary>
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)
};
}
/// <summary>
/// Determina automaticamente il tipo del valore
/// </summary>
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"
};
}
/// <summary>
/// Ottiene una descrizione user-friendly del mapping
/// </summary>
public string GetDescription()
{
return Type switch
{
MappingType.FieldMapping => $"{SourceField} → {DestinationField}",
MappingType.DefaultValue => $"{DestinationField} = {DefaultValue ?? "null"} ({DefaultValueType})",
_ => "Unknown"
};
}
}
/// <summary>
/// Helper per la conversione tra vecchio formato (Dictionary) e nuovo formato (FieldMappingEntry)
/// </summary>
public static class MappingConverter
{
/// <summary>
/// Converte il vecchio formato Dictionary in lista di FieldMappingEntry
/// </summary>
public static List<FieldMappingEntry> FromDictionary(Dictionary<string, string> oldMappings)
{
var entries = new List<FieldMappingEntry>();
foreach (var mapping in oldMappings)
{
entries.Add(FieldMappingEntry.CreateFieldMapping(mapping.Key, mapping.Value));
}
return entries;
}
/// <summary>
/// Converte una lista di FieldMappingEntry nel vecchio formato Dictionary (solo field mappings)
/// </summary>
public static Dictionary<string, string> ToDictionary(List<FieldMappingEntry> entries)
{
var dictionary = new Dictionary<string, string>();
foreach (var entry in entries.Where(e => e.Type == MappingType.FieldMapping && !string.IsNullOrEmpty(e.SourceField)))
{
dictionary[entry.SourceField!] = entry.DestinationField;
}
return dictionary;
}
/// <summary>
/// Ottiene solo i valori di default da una lista di entries
/// </summary>
public static Dictionary<string, (object? Value, string? Type)> GetDefaultValues(List<FieldMappingEntry> entries)
{
var defaults = new Dictionary<string, (object?, string?)>();
foreach (var entry in entries.Where(e => e.Type == MappingType.DefaultValue))
{
defaults[entry.DestinationField] = (entry.DefaultValue, entry.DefaultValueType);
}
return defaults;
}
}
}
@@ -233,6 +233,7 @@ public class CredentialService : ICredentialService
additionalParams["ApiVersion"] = credential.ApiVersion; additionalParams["ApiVersion"] = credential.ApiVersion;
additionalParams["IsSandbox"] = credential.IsSandbox.ToString(); additionalParams["IsSandbox"] = credential.IsSandbox.ToString();
additionalParams["UseSoapApi"] = credential.UseSoapApi.ToString(); additionalParams["UseSoapApi"] = credential.UseSoapApi.ToString();
additionalParams["GrantType"] = credential.GrantType.ToString();
if (!string.IsNullOrEmpty(credential.RefreshToken)) if (!string.IsNullOrEmpty(credential.RefreshToken))
additionalParams["RefreshToken"] = credential.RefreshToken; additionalParams["RefreshToken"] = credential.RefreshToken;
if (!string.IsNullOrEmpty(credential.AccessToken)) if (!string.IsNullOrEmpty(credential.AccessToken))
@@ -523,7 +524,8 @@ public class CredentialService : ICredentialService
["SecurityToken"] = credential.SecurityToken, ["SecurityToken"] = credential.SecurityToken,
["ApiVersion"] = credential.ApiVersion, ["ApiVersion"] = credential.ApiVersion,
["IsSandbox"] = credential.IsSandbox.ToString(), ["IsSandbox"] = credential.IsSandbox.ToString(),
["UseSoapApi"] = credential.UseSoapApi.ToString() ["UseSoapApi"] = credential.UseSoapApi.ToString(),
["GrantType"] = credential.GrantType.ToString()
}; };
// Aggiungi ClientId e ClientSecret se forniti // Aggiungi ClientId e ClientSecret se forniti
@@ -793,6 +795,8 @@ public class CredentialService : ICredentialService
credential.IsSandbox = sandbox; credential.IsSandbox = sandbox;
if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap)) if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap))
credential.UseSoapApi = soap; credential.UseSoapApi = soap;
if (additionalParams.TryGetValue("GrantType", out var grantTypeStr) && Enum.TryParse<SalesforceGrantType>(grantTypeStr, out var grantType))
credential.GrantType = grantType;
if (additionalParams.TryGetValue("RefreshToken", out var refreshToken)) if (additionalParams.TryGetValue("RefreshToken", out var refreshToken))
credential.RefreshToken = refreshToken; credential.RefreshToken = refreshToken;
if (additionalParams.TryGetValue("AccessToken", out var accessToken)) if (additionalParams.TryGetValue("AccessToken", out var accessToken))
@@ -806,7 +810,7 @@ public class CredentialService : ICredentialService
{ {
"CompanyDatabase", "Language", "Version", "UseTrustedConnection", "CompanyDatabase", "Language", "Version", "UseTrustedConnection",
"SecurityToken", "ClientId", "ClientSecret", "ApiVersion", "SecurityToken", "ClientId", "ClientSecret", "ApiVersion",
"IsSandbox", "UseSoapApi", "RefreshToken", "AccessToken", "TokenExpiry" "IsSandbox", "UseSoapApi", "GrantType", "RefreshToken", "AccessToken", "TokenExpiry"
}; };
foreach (var param in additionalParams) foreach (var param in additionalParams)
@@ -915,6 +919,8 @@ public class CredentialService : ICredentialService
credential.IsSandbox = sandbox; credential.IsSandbox = sandbox;
if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap)) if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap))
credential.UseSoapApi = soap; credential.UseSoapApi = soap;
if (additionalParams.TryGetValue("GrantType", out var grantTypeStr) && Enum.TryParse<SalesforceGrantType>(grantTypeStr, out var grantType))
credential.GrantType = grantType;
if (additionalParams.TryGetValue("RefreshToken", out var refreshToken)) if (additionalParams.TryGetValue("RefreshToken", out var refreshToken))
credential.RefreshToken = refreshToken; credential.RefreshToken = refreshToken;
if (additionalParams.TryGetValue("AccessToken", out var accessToken)) if (additionalParams.TryGetValue("AccessToken", out var accessToken))
@@ -109,6 +109,8 @@ public class DataCouplerProfileService : IDataCouplerProfileService
existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson; existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
existingProfile.IsActive = profile.IsActive; existingProfile.IsActive = profile.IsActive;
@@ -201,6 +203,100 @@ public class DataCouplerProfileService : IDataCouplerProfileService
} }
} }
/// <summary>
/// Serializza la lista di External ID Relationships in JSON
/// </summary>
public string SerializeExternalIdRelationships(List<ExternalIdRelationshipDto>? relationships)
{
if (relationships == null || !relationships.Any())
return string.Empty;
return JsonSerializer.Serialize(relationships, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
/// <summary>
/// <summary>
/// Deserializza il JSON delle External ID Relationships
/// </summary>
public List<ExternalIdRelationshipDto> DeserializeExternalIdRelationships(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new List<ExternalIdRelationshipDto>();
try
{
return JsonSerializer.Deserialize<List<ExternalIdRelationshipDto>>(json, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}) ?? new List<ExternalIdRelationshipDto>();
}
catch
{
return new List<ExternalIdRelationshipDto>();
}
}
/// <summary>
/// Serializza i default values in JSON
/// </summary>
public string SerializeDefaultValues(Dictionary<string, (object? Value, string? Type)>? defaultValues)
{
if (defaultValues == null || !defaultValues.Any())
return string.Empty;
// Converti in un formato serializzabile (Dictionary<string, DefaultValueDto>)
var serializable = new Dictionary<string, DefaultValueDto>();
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
});
}
/// <summary>
/// Deserializza il JSON dei default values
/// </summary>
public Dictionary<string, (object? Value, string? Type)> DeserializeDefaultValues(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new Dictionary<string, (object?, string?)>();
try
{
var deserialized = JsonSerializer.Deserialize<Dictionary<string, DefaultValueDto>>(json, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
if (deserialized == null)
return new Dictionary<string, (object?, string?)>();
// Converti nel formato tuple
var result = new Dictionary<string, (object?, string?)>();
foreach (var entry in deserialized)
{
result[entry.Key] = (entry.Value.Value, entry.Value.Type);
}
return result;
}
catch
{
return new Dictionary<string, (object?, string?)>();
}
}
/// <summary> /// <summary>
/// Converte un DataCouplerProfile in DTO /// Converte un DataCouplerProfile in DTO
/// </summary> /// </summary>
@@ -226,6 +322,8 @@ public class DataCouplerProfileService : IDataCouplerProfileService
DestinationTable = profile.DestinationTable, DestinationTable = profile.DestinationTable,
DestinationEndpoint = profile.DestinationEndpoint, DestinationEndpoint = profile.DestinationEndpoint,
FieldMappings = DeserializeFieldMappings(profile.FieldMappingJson), FieldMappings = DeserializeFieldMappings(profile.FieldMappingJson),
DefaultValues = DeserializeDefaultValues(profile.DefaultValuesJson),
ExternalIdRelationships = DeserializeExternalIdRelationships(profile.ExternalIdRelationshipsJson),
SourceKeyField = profile.SourceKeyField, SourceKeyField = profile.SourceKeyField,
UseRecordAssociations = profile.UseRecordAssociations UseRecordAssociations = profile.UseRecordAssociations
}; };
@@ -254,6 +352,8 @@ public class DataCouplerProfileService : IDataCouplerProfileService
DestinationTable = dto.DestinationTable, DestinationTable = dto.DestinationTable,
DestinationEndpoint = dto.DestinationEndpoint, DestinationEndpoint = dto.DestinationEndpoint,
FieldMappingJson = SerializeFieldMappings(dto.FieldMappings), FieldMappingJson = SerializeFieldMappings(dto.FieldMappings),
DefaultValuesJson = SerializeDefaultValues(dto.DefaultValues),
ExternalIdRelationshipsJson = SerializeExternalIdRelationships(dto.ExternalIdRelationships),
SourceKeyField = dto.SourceKeyField, SourceKeyField = dto.SourceKeyField,
UseRecordAssociations = dto.UseRecordAssociations, UseRecordAssociations = dto.UseRecordAssociations,
CreatedBy = createdBy CreatedBy = createdBy
@@ -112,6 +112,16 @@ public interface IKeyAssociationService
/// </summary> /// </summary>
Task<KeyAssociation?> FindAssociationByKeyValueParallelAsync(string keyValue); Task<KeyAssociation?> FindAssociationByKeyValueParallelAsync(string keyValue);
/// <summary>
/// Versione bulk: ricerca in un colpo solo tutte le associazioni attive per la combinazione
/// (KeyValue ∈ keyValues, DestinationEntity, RestCredentialName) usando una query SQL IN(...).
/// Riduce drasticamente le query SQLite quando si processano molti record.
/// </summary>
Task<Dictionary<string, KeyAssociation>> FindAssociationsByKeyValuesBulkAsync(
IEnumerable<string> keyValues,
string destinationEntity,
string restCredentialName);
/// <summary> /// <summary>
/// Versione thread-safe per operazioni parallele - Elimina associazione /// Versione thread-safe per operazioni parallele - Elimina associazione
/// </summary> /// </summary>
@@ -358,6 +358,63 @@ public class KeyAssociationService : IKeyAssociationService
} }
} }
/// <summary>
/// Bulk lookup delle associazioni: una sola query con WHERE KeyValue IN (...).
/// Per N chiavi sostituisce fino a 2N query SQLite del flusso per-record.
/// </summary>
public async Task<Dictionary<string, KeyAssociation>> FindAssociationsByKeyValuesBulkAsync(
IEnumerable<string> keyValues,
string destinationEntity,
string restCredentialName)
{
var distinctKeys = keyValues
.Where(k => !string.IsNullOrEmpty(k))
.Distinct()
.ToList();
if (distinctKeys.Count == 0)
return new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
try
{
// SQLite ha un limite hardcoded di ~999 parametri per query: chunk per sicurezza.
const int chunkSize = 500;
var result = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
for (int i = 0; i < distinctKeys.Count; i += chunkSize)
{
var chunk = distinctKeys.Skip(i).Take(chunkSize).ToList();
var associations = await _context.KeyAssociations
.AsNoTracking()
.Where(ka => ka.IsActive &&
ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
chunk.Contains(ka.KeyValue))
.ToListAsync();
// Se ci sono duplicati (KeyValue ripetuto), tieni il più recente
foreach (var assoc in associations
.GroupBy(a => a.KeyValue)
.Select(g => g.OrderByDescending(a => a.UpdatedAt ?? a.CreatedAt).First()))
{
result[assoc.KeyValue] = assoc;
}
}
_logger.LogDebug("BULK: Ricerca associazioni completata - {Found}/{Total} match per {Entity}/{Credential}",
result.Count, distinctKeys.Count, destinationEntity, restCredentialName);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "BULK: Errore nella ricerca bulk delle associazioni ({Count} chiavi, Entity={Entity})",
distinctKeys.Count, destinationEntity);
throw;
}
}
/// <summary> /// <summary>
/// Versione thread-safe per operazioni parallele - Delete association /// Versione thread-safe per operazioni parallele - Delete association
/// </summary> /// </summary>
@@ -0,0 +1,112 @@
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using System.Runtime.InteropServices;
namespace CredentialManager.Services;
/// <summary>
/// Informazioni su un provider OLE DB installato nel sistema
/// </summary>
public class OleDbProviderInfo
{
/// <summary>ProgID del provider (es. VFPOLEDB.1, Microsoft.ACE.OLEDB.12.0)</summary>
public string ProgId { get; set; } = string.Empty;
/// <summary>Descrizione leggibile del provider</summary>
public string Description { get; set; } = string.Empty;
/// <summary>Indica se è un provider Visual FoxPro (solo 32-bit)</summary>
public bool IsVfpProvider { get; set; }
/// <summary>Nota aggiuntiva (es. avviso 32-bit)</summary>
public string? Note { get; set; }
}
/// <summary>
/// Interfaccia per il servizio di discovery dei provider OLE DB installati
/// </summary>
public interface IOleDbProviderDiscoveryService
{
/// <summary>
/// Ottiene la lista dei provider OLE DB noti installati nel sistema
/// </summary>
List<OleDbProviderInfo> GetInstalledProviders();
/// <summary>
/// Verifica se almeno un provider Visual FoxPro è installato
/// </summary>
bool IsVfpProviderInstalled();
}
/// <summary>
/// Servizio per la discovery dei provider OLE DB installati tramite il registro di Windows.
/// Controlla un elenco di provider noti verificando la presenza della chiave HKEY_CLASSES_ROOT\{ProgId}.
/// </summary>
public class OleDbProviderDiscoveryService : IOleDbProviderDiscoveryService
{
private readonly ILogger<OleDbProviderDiscoveryService> _logger;
/// <summary>
/// Provider OLE DB noti: ProgID → Descrizione
/// </summary>
private static readonly (string ProgId, string Description, bool IsVfp)[] KnownProviders =
{
("VFPOLEDB.1", "Microsoft OLE DB Provider per Visual FoxPro 8.0/9.0 (32-bit)", true),
("VFPOLEDB", "Microsoft OLE DB Provider per Visual FoxPro (32-bit)", true),
("Microsoft.ACE.OLEDB.12.0", "Microsoft Access Database Engine 2010", false),
("Microsoft.ACE.OLEDB.16.0", "Microsoft Access Database Engine 2016", false),
("Microsoft.Jet.OLEDB.4.0", "Microsoft Jet 4.0 OLE DB Provider (Access/Excel 97-2003)", false),
("SQLOLEDB", "Microsoft OLE DB Provider for SQL Server (legacy)", false),
("SQLNCLI11", "SQL Server Native Client 11.0", false),
("MSOLEDBSQL", "Microsoft OLE DB Driver for SQL Server", false),
("MSDAORA", "Microsoft OLE DB Provider for Oracle (legacy)", false),
};
public OleDbProviderDiscoveryService(ILogger<OleDbProviderDiscoveryService> logger)
{
_logger = logger;
}
public List<OleDbProviderInfo> GetInstalledProviders()
{
var installed = new List<OleDbProviderInfo>();
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
_logger.LogWarning("OLE DB è supportato solo su Windows. Nessun provider restituito.");
return installed;
}
foreach (var (progId, description, isVfp) in KnownProviders)
{
try
{
using var key = Registry.ClassesRoot.OpenSubKey(progId);
if (key != null)
{
var note = isVfp ? "⚠ Solo 32-bit — pubblicare con --runtime win-x86" : null;
installed.Add(new OleDbProviderInfo
{
ProgId = progId,
Description = description,
IsVfpProvider = isVfp,
Note = note
});
_logger.LogDebug("Provider OLE DB trovato: {ProgId}", progId);
}
}
catch (Exception ex)
{
_logger.LogDebug("Errore nel verificare il provider {ProgId}: {Message}", progId, ex.Message);
}
}
_logger.LogInformation("Provider OLE DB installati trovati: {Count}", installed.Count);
return installed;
}
public bool IsVfpProviderInstalled()
{
return GetInstalledProviders().Any(p => p.IsVfpProvider);
}
}
@@ -85,6 +85,14 @@ public interface IDataConnectionCredentialService
Task<KeyAssociation?> FindKeyAssociationByValueParallelAsync(string keyValue); Task<KeyAssociation?> FindKeyAssociationByValueParallelAsync(string keyValue);
Task<bool> DeleteKeyAssociationParallelAsync(int id); Task<bool> DeleteKeyAssociationParallelAsync(int id);
/// <summary>
/// Bulk lookup associazioni - una sola query SQLite per N chiavi.
/// </summary>
Task<Dictionary<string, KeyAssociation>> FindKeyAssociationsByValuesBulkAsync(
IEnumerable<string> keyValues,
string destinationEntity,
string restCredentialName);
// Deletion synchronization operations // Deletion synchronization operations
Task<int> MarkDeletedAssociationsAsync(List<string> sourceKeyValues, string destinationEntity, string restCredentialName); Task<int> MarkDeletedAssociationsAsync(List<string> sourceKeyValues, string destinationEntity, string restCredentialName);
Task<List<KeyAssociation>> GetPendingDeletionsAsync(string destinationEntity, string restCredentialName); Task<List<KeyAssociation>> GetPendingDeletionsAsync(string destinationEntity, string restCredentialName);
@@ -22,6 +22,7 @@ public static class CredentialExtensions
CredentialManager.Models.DatabaseType.DB2 => DataConnection.Enums.DatabaseType.DB2, CredentialManager.Models.DatabaseType.DB2 => DataConnection.Enums.DatabaseType.DB2,
CredentialManager.Models.DatabaseType.SapHana => DataConnection.Enums.DatabaseType.SapHana, CredentialManager.Models.DatabaseType.SapHana => DataConnection.Enums.DatabaseType.SapHana,
CredentialManager.Models.DatabaseType.Odbc => DataConnection.Enums.DatabaseType.Odbc, CredentialManager.Models.DatabaseType.Odbc => DataConnection.Enums.DatabaseType.Odbc,
CredentialManager.Models.DatabaseType.OleDb => DataConnection.Enums.DatabaseType.OleDb,
_ => throw new NotSupportedException($"Database type {credentialDbType} not supported") _ => throw new NotSupportedException($"Database type {credentialDbType} not supported")
}; };
} }
@@ -41,6 +42,7 @@ public static class CredentialExtensions
DataConnection.Enums.DatabaseType.DB2 => CredentialManager.Models.DatabaseType.DB2, DataConnection.Enums.DatabaseType.DB2 => CredentialManager.Models.DatabaseType.DB2,
DataConnection.Enums.DatabaseType.SapHana => CredentialManager.Models.DatabaseType.SapHana, DataConnection.Enums.DatabaseType.SapHana => CredentialManager.Models.DatabaseType.SapHana,
DataConnection.Enums.DatabaseType.Odbc => CredentialManager.Models.DatabaseType.Odbc, DataConnection.Enums.DatabaseType.Odbc => CredentialManager.Models.DatabaseType.Odbc,
DataConnection.Enums.DatabaseType.OleDb => CredentialManager.Models.DatabaseType.OleDb,
_ => throw new NotSupportedException($"Database type {dataConnectionDbType} not supported") _ => throw new NotSupportedException($"Database type {dataConnectionDbType} not supported")
}; };
} }
@@ -84,23 +84,3 @@ public class DataConnectionCredentialOptions
/// </summary> /// </summary>
public int DatabaseTimeout { get; set; } = 30; public int DatabaseTimeout { get; set; } = 30;
} }
/// <summary>
/// Interfaccia per il servizio di gestione credenziali specifico per DataConnection
/// Questa interfaccia estende le funzionalità base di CredentialManager
/// con metodi specifici per l'integrazione con DataConnection
/// </summary>
public interface IDataConnectionCredentialServiceConfiguration
{
/// <summary>
/// Configura il servizio con le opzioni specificate
/// </summary>
/// <param name="options">Le opzioni di configurazione</param>
void Configure(DataConnectionCredentialOptions options);
/// <summary>
/// Verifica la connessione al database delle credenziali
/// </summary>
/// <returns>True se la connessione è valida</returns>
Task<bool> TestConnectionAsync();
}
@@ -168,16 +168,7 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
if (credential == null) if (credential == null)
throw new InvalidOperationException($"REST API credential '{credentialName}' not found"); throw new InvalidOperationException($"REST API credential '{credentialName}' not found");
var options = new DataConnection.REST.Configuration.RestServiceOptions var options = BuildRestServiceOptions(credential);
{
BaseUrl = credential.BaseUrl,
ApiKey = credential.ApiKey,
Username = credential.Username,
Password = credential.Password,
AuthToken = credential.AuthToken,
TimeoutSeconds = credential.TimeoutSeconds,
IgnoreSslErrors = credential.IgnoreSslErrors
};
_logger.LogDebug("Created RestServiceOptions for credential: {Name} ({BaseUrl})", _logger.LogDebug("Created RestServiceOptions for credential: {Name} ({BaseUrl})",
credentialName, credential.BaseUrl); credentialName, credential.BaseUrl);
@@ -191,19 +182,42 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
if (credential == null) if (credential == null)
throw new InvalidOperationException($"REST API credential with ID '{credentialId}' not found"); throw new InvalidOperationException($"REST API credential with ID '{credentialId}' not found");
var options = BuildRestServiceOptions(credential);
_logger.LogDebug("Created RestServiceOptions for credential ID: {Id} ({BaseUrl})",
credentialId, credential.BaseUrl);
return options;
}
private static DataConnection.REST.Configuration.RestServiceOptions BuildRestServiceOptions(RestApiCredential credential)
{
var options = new DataConnection.REST.Configuration.RestServiceOptions var options = new DataConnection.REST.Configuration.RestServiceOptions
{ {
BaseUrl = credential.BaseUrl, BaseUrl = credential.BaseUrl,
ApiKey = credential.ApiKey,
Username = credential.Username, Username = credential.Username,
Password = credential.Password, Password = credential.Password,
AuthToken = credential.AuthToken,
TimeoutSeconds = credential.TimeoutSeconds, TimeoutSeconds = credential.TimeoutSeconds,
IgnoreSslErrors = credential.IgnoreSslErrors IgnoreSslErrors = credential.IgnoreSslErrors
}; };
_logger.LogDebug("Created RestServiceOptions for credential ID: {Id} ({BaseUrl})", // Mapping coerente con DataConnectionFactory.CreateRestServiceClientAsync
credentialId, credential.BaseUrl); switch (credential.ServiceType)
{
case RestServiceType.Salesforce:
options.ApiKey = credential.ClientId;
options.AuthToken = credential.ClientSecret;
options.SalesforceGrantType = credential.GrantType;
break;
case RestServiceType.SapB1ServiceLayer:
options.ApiKey = credential.CompanyDatabase;
options.AuthToken = credential.AuthToken;
break;
default:
options.ApiKey = credential.ApiKey;
options.AuthToken = credential.AuthToken;
break;
}
return options; return options;
} }
@@ -251,6 +265,7 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
CredentialManager.Models.DatabaseType.Oracle => await TestOracleConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Oracle => await TestOracleConnection(connectionString, credential),
CredentialManager.Models.DatabaseType.Sqlite => await TestSqliteConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Sqlite => await TestSqliteConnection(connectionString, credential),
CredentialManager.Models.DatabaseType.Odbc => await TestOdbcConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Odbc => await TestOdbcConnection(connectionString, credential),
CredentialManager.Models.DatabaseType.OleDb => await TestOleDbConnection(connectionString, credential),
_ => (false, $"Test di connessione non implementato per {credential.DatabaseType}") _ => (false, $"Test di connessione non implementato per {credential.DatabaseType}")
}; };
} }
@@ -404,6 +419,46 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
} }
} }
private async Task<(bool Success, string Message)> TestOleDbConnection(string connectionString, DatabaseCredential credential)
{
try
{
using var connection = new System.Data.OleDb.OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
var details = new System.Text.StringBuilder();
details.AppendLine("Connessione OLE DB stabilita con successo!");
details.AppendLine();
details.AppendLine("Dettagli:");
details.AppendLine($"- Provider: {connection.Provider}");
if (!string.IsNullOrEmpty(connection.Database))
details.AppendLine($"- Database: {connection.Database}");
if (!string.IsNullOrEmpty(credential.DatabaseName))
details.AppendLine($"- Data Source: {credential.DatabaseName}");
details.AppendLine($"- Timeout: {credential.CommandTimeout}s");
return (true, details.ToString());
}
catch (System.Data.OleDb.OleDbException oleDbEx)
{
var errorDetails = new System.Text.StringBuilder();
errorDetails.AppendLine($"Errore OLE DB: {oleDbEx.Message}");
errorDetails.AppendLine();
errorDetails.AppendLine("Dettagli errori:");
foreach (System.Data.OleDb.OleDbError error in oleDbEx.Errors)
{
errorDetails.AppendLine($"- [{error.SQLState}] {error.Message}");
errorDetails.AppendLine($" Source: {error.Source}");
}
return (false, errorDetails.ToString());
}
catch (Exception ex)
{
return (false, $"Errore OLE DB: {ex.Message}");
}
}
public async Task<(bool Success, string Message)> TestRestApiConnectionAsync(string credentialName) public async Task<(bool Success, string Message)> TestRestApiConnectionAsync(string credentialName)
{ {
try try
@@ -509,8 +564,8 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
{ {
try try
{ {
_logger.LogInformation("Testing Salesforce authentication for {Name} ({BaseUrl})", _logger.LogInformation("Testing Salesforce authentication for {Name} ({BaseUrl}, GrantType={GrantType})",
credential.Name, credential.BaseUrl); credential.Name, credential.BaseUrl, credential.GrantType);
_logger.LogDebug("Salesforce credential details: Username={Username}, HasPassword={HasPassword}, HasSecurityToken={HasSecurityToken}, HasClientId={HasClientId}, HasClientSecret={HasClientSecret}", _logger.LogDebug("Salesforce credential details: Username={Username}, HasPassword={HasPassword}, HasSecurityToken={HasSecurityToken}, HasClientId={HasClientId}, HasClientSecret={HasClientSecret}",
credential.Username, !string.IsNullOrEmpty(credential.Password), !string.IsNullOrEmpty(credential.SecurityToken), credential.Username, !string.IsNullOrEmpty(credential.Password), !string.IsNullOrEmpty(credential.SecurityToken),
@@ -519,49 +574,69 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
using var httpClient = new HttpClient(); using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(credential.TimeoutSeconds); httpClient.Timeout = TimeSpan.FromSeconds(credential.TimeoutSeconds);
// Test di autenticazione OAuth2
var tokenUrl = credential.BaseUrl.TrimEnd('/') + "/services/oauth2/token"; var tokenUrl = credential.BaseUrl.TrimEnd('/') + "/services/oauth2/token";
var tokenData = new List<KeyValuePair<string, string>> List<KeyValuePair<string, string>> tokenData;
{ string flowLabel;
new("grant_type", "password"),
new("username", credential.Username ?? "")
};
// Aggiungiamo password + security token se disponibile if (credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
var password = credential.Password ?? "";
if (!string.IsNullOrEmpty(credential.SecurityToken))
{ {
password += credential.SecurityToken; // Client Credentials flow — server-to-server, no user
} if (string.IsNullOrEmpty(credential.ClientId) || string.IsNullOrEmpty(credential.ClientSecret))
tokenData.Add(new("password", password)); {
return (false, "Flusso client_credentials richiede ClientId e ClientSecret configurati.");
}
// Aggiungiamo client credentials se disponibili tokenData = new List<KeyValuePair<string, string>>
if (!string.IsNullOrEmpty(credential.ClientId)) {
{ new("grant_type", "client_credentials"),
tokenData.Add(new("client_id", credential.ClientId)); new("client_id", credential.ClientId),
new("client_secret", credential.ClientSecret)
};
flowLabel = "client_credentials";
} }
if (!string.IsNullOrEmpty(credential.ClientSecret)) else
{ {
tokenData.Add(new("client_secret", credential.ClientSecret)); // Password flow (default)
var password = credential.Password ?? "";
if (!string.IsNullOrEmpty(credential.SecurityToken))
{
password += credential.SecurityToken;
}
tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "password"),
new("username", credential.Username ?? ""),
new("password", password)
};
if (!string.IsNullOrEmpty(credential.ClientId))
{
tokenData.Add(new("client_id", credential.ClientId));
}
if (!string.IsNullOrEmpty(credential.ClientSecret))
{
tokenData.Add(new("client_secret", credential.ClientSecret));
}
flowLabel = "password";
} }
_logger.LogDebug("Posting to Salesforce token URL: {TokenUrl}", tokenUrl); _logger.LogDebug("Posting to Salesforce token URL: {TokenUrl} (flow={Flow})", tokenUrl, flowLabel);
var tokenContent = new FormUrlEncodedContent(tokenData); var tokenContent = new FormUrlEncodedContent(tokenData);
var response = await httpClient.PostAsync(tokenUrl, tokenContent); var response = await httpClient.PostAsync(tokenUrl, tokenContent);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var responseContent = await response.Content.ReadAsStringAsync(); _logger.LogInformation("Salesforce authentication ({Flow}) successful for {Name}", flowLabel, credential.Name);
_logger.LogInformation("Salesforce authentication successful for {Name}", credential.Name); return (true, $"Autenticazione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.BaseUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2 ({flowLabel})\n- Timeout: {credential.TimeoutSeconds}s");
return (true, $"Autenticazione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.BaseUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2\n- Timeout: {credential.TimeoutSeconds}s");
} }
else else
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Salesforce authentication failed for {Name}. Status: {StatusCode}, Response: {Response}", _logger.LogWarning("Salesforce authentication ({Flow}) failed for {Name}. Status: {StatusCode}, Response: {Response}",
credential.Name, response.StatusCode, errorContent); flowLabel, credential.Name, response.StatusCode, errorContent);
return (false, $"Autenticazione Salesforce fallita. Status: {response.StatusCode}\nDettagli: {errorContent}"); return (false, $"Autenticazione Salesforce ({flowLabel}) fallita. Status: {response.StatusCode}\nDettagli: {errorContent}");
} }
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
@@ -863,23 +938,39 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
try try
{ {
var tokenUrl = credential.LoginUrl.TrimEnd('/') + "/services/oauth2/token"; var tokenUrl = credential.LoginUrl.TrimEnd('/') + "/services/oauth2/token";
List<KeyValuePair<string, string>> tokenData;
var tokenData = new List<KeyValuePair<string, string>> if (credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{ {
new("grant_type", "password"), // Client Credentials flow — server-to-server, no user
new("username", credential.Username), tokenData = new List<KeyValuePair<string, string>>
new("password", credential.Password + credential.SecurityToken), {
new("client_id", credential.ClientId ?? ""), new("grant_type", "client_credentials"),
new("client_secret", credential.ClientSecret ?? "") new("client_id", credential.ClientId ?? ""),
}; new("client_secret", credential.ClientSecret ?? "")
};
}
else
{
// Password flow (default)
tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "password"),
new("username", credential.Username),
new("password", credential.Password + credential.SecurityToken),
new("client_id", credential.ClientId ?? ""),
new("client_secret", credential.ClientSecret ?? "")
};
}
var tokenContent = new FormUrlEncodedContent(tokenData); var tokenContent = new FormUrlEncodedContent(tokenData);
var response = await httpClient.PostAsync(tokenUrl, tokenContent); var response = await httpClient.PostAsync(tokenUrl, tokenContent);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var responseContent = await response.Content.ReadAsStringAsync(); var flowLabel = credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials
return (true, $"Connessione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.LoginUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2\n- Timeout: {credential.TimeoutSeconds}s"); ? "client_credentials" : "password";
return (true, $"Connessione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.LoginUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2 ({flowLabel})\n- Timeout: {credential.TimeoutSeconds}s");
} }
else else
{ {
@@ -1017,6 +1108,14 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
return await _keyAssociationService.FindAssociationByKeyValueParallelAsync(keyValue); return await _keyAssociationService.FindAssociationByKeyValueParallelAsync(keyValue);
} }
public async Task<Dictionary<string, KeyAssociation>> FindKeyAssociationsByValuesBulkAsync(
IEnumerable<string> keyValues,
string destinationEntity,
string restCredentialName)
{
return await _keyAssociationService.FindAssociationsByKeyValuesBulkAsync(keyValues, destinationEntity, restCredentialName);
}
public async Task<bool> DeleteKeyAssociationParallelAsync(int id) public async Task<bool> DeleteKeyAssociationParallelAsync(int id)
{ {
return await _keyAssociationService.DeleteAssociationParallelAsync(id); return await _keyAssociationService.DeleteAssociationParallelAsync(id);
@@ -19,7 +19,10 @@ public class DatabaseSchemaProviderFactory
{ {
return databaseType switch return databaseType switch
{ {
DatabaseType.SqlServer => new SqlServerSchemaProvider(), DatabaseType.Odbc => new OdbcSchemaProvider(), // Aggiungere qui altri provider quando implementati DatabaseType.SqlServer => new SqlServerSchemaProvider(),
DatabaseType.Odbc => new OdbcSchemaProvider(),
DatabaseType.OleDb => new OleDbSchemaProvider(),
// Aggiungere qui altri provider quando implementati
// DatabaseType.MySql => new MySqlSchemaProvider(), // DatabaseType.MySql => new MySqlSchemaProvider(),
// DatabaseType.PostgreSql => new PostgreSqlSchemaProvider(), // DatabaseType.PostgreSql => new PostgreSqlSchemaProvider(),
// DatabaseType.Oracle => new OracleSchemaProvider(), // DatabaseType.Oracle => new OracleSchemaProvider(),
@@ -579,4 +579,94 @@ public class EFCoreDatabaseManager : IDatabaseManager
return null; return null;
} }
} }
/// <inheritdoc />
public async Task<bool> UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary<string, object?> record)
{
try
{
if (_context.Database.GetDbConnection().State != ConnectionState.Open)
await _context.Database.OpenConnectionAsync();
var connection = _context.Database.GetDbConnection();
// Determina il riferimento alla tabella (con o senza schema)
string tableRef;
if (tableName.Contains('.'))
{
var parts = tableName.Split('.', 2);
tableRef = $"[{parts[0]}].[{parts[1]}]";
}
else
{
tableRef = $"[{tableName}]";
}
// Controlla se il record esiste già
using var checkCmd = connection.CreateCommand();
checkCmd.CommandText = $"SELECT COUNT(*) FROM {tableRef} WHERE [{keyField}] = @p0";
var checkParam = checkCmd.CreateParameter();
checkParam.ParameterName = "@p0";
checkParam.Value = keyValue ?? DBNull.Value;
checkCmd.Parameters.Add(checkParam);
var countResult = await checkCmd.ExecuteScalarAsync();
bool exists = Convert.ToInt64(countResult ?? 0L) > 0;
if (exists)
{
// UPDATE
var fields = record.Keys.ToList();
var setClauses = fields.Select((f, i) => $"[{f}] = @p{i}").ToList();
var updateSql = $"UPDATE {tableRef} SET {string.Join(", ", setClauses)} WHERE [{keyField}] = @p{setClauses.Count}";
using var updateCmd = connection.CreateCommand();
updateCmd.CommandText = updateSql;
for (int i = 0; i < fields.Count; i++)
{
var p = updateCmd.CreateParameter();
p.ParameterName = $"@p{i}";
p.Value = record[fields[i]] ?? DBNull.Value;
updateCmd.Parameters.Add(p);
}
// Aggiunge il parametro per la WHERE
var keyParam = updateCmd.CreateParameter();
keyParam.ParameterName = $"@p{fields.Count}";
keyParam.Value = keyValue ?? DBNull.Value;
updateCmd.Parameters.Add(keyParam);
await updateCmd.ExecuteNonQueryAsync();
}
else
{
// INSERT
var fields = record.Keys.ToList();
var fieldNames = string.Join(", ", fields.Select(f => $"[{f}]"));
var paramPlaceholders = string.Join(", ", fields.Select((_, i) => $"@p{i}"));
var insertSql = $"INSERT INTO {tableRef} ({fieldNames}) VALUES ({paramPlaceholders})";
using var insertCmd = connection.CreateCommand();
insertCmd.CommandText = insertSql;
for (int i = 0; i < fields.Count; i++)
{
var p = insertCmd.CreateParameter();
p.ParameterName = $"@p{i}";
p.Value = record[fields[i]] ?? DBNull.Value;
insertCmd.Parameters.Add(p);
}
await insertCmd.ExecuteNonQueryAsync();
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Errore nell'upsert in {tableName}: {ex.Message}");
return false;
}
}
} }
@@ -0,0 +1,335 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.OleDb;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using DataConnection.Interfaces;
namespace DataConnection.EF.SchemaProviders;
/// <summary>
/// Provider di schema per database OLE DB (incluso Visual FoxPro)
/// Utilizza GetOleDbSchemaTable per ottenere metadati in modo compatibile con VFP e altri provider OLE DB
/// </summary>
public class OleDbSchemaProvider : IDatabaseSchemaProvider
{
public async Task<IDictionary<string, IEnumerable<DbColumnInfo>>> GetDatabaseSchemaAsync(string connectionString)
{
var result = new Dictionary<string, IEnumerable<DbColumnInfo>>();
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
throw new PlatformNotSupportedException("OLE DB è supportato solo su Windows.");
try
{
using var connection = new OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
Console.WriteLine($"OLE DB Schema Provider - Provider: {connection.Provider}");
var tableNames = GetTableNamesFromConnection(connection);
Console.WriteLine($"Trovate {tableNames.Count} tabelle");
foreach (var tableName in tableNames)
{
try
{
var columns = GetTableColumnsFromConnection(connection, tableName);
if (columns.Any())
{
result[tableName] = columns;
Console.WriteLine($"Tabella {tableName}: {columns.Count()} colonne");
}
}
catch (Exception ex)
{
Console.WriteLine($"Errore nel leggere le colonne della tabella {tableName}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Errore in OleDbSchemaProvider.GetDatabaseSchemaAsync: {ex.Message}");
throw;
}
return result;
}
public async Task<IEnumerable<string>> GetTableNamesAsync(string connectionString)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return Enumerable.Empty<string>();
try
{
using var connection = new OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
return GetTableNamesFromConnection(connection);
}
catch (Exception ex)
{
Console.WriteLine($"Errore in OleDbSchemaProvider.GetTableNamesAsync: {ex.Message}");
return Enumerable.Empty<string>();
}
}
public async Task<IEnumerable<DbColumnInfo>> GetTableSchemaAsync(string connectionString, string tableName)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return Enumerable.Empty<DbColumnInfo>();
try
{
using var connection = new OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
return GetTableColumnsFromConnection(connection, tableName);
}
catch (Exception ex)
{
Console.WriteLine($"Errore in OleDbSchemaProvider.GetTableSchemaAsync per {tableName}: {ex.Message}");
return Enumerable.Empty<DbColumnInfo>();
}
}
public async Task<IEnumerable<string>> GetAvailableDatabasesAsync(string connectionString)
{
// OLE DB file-based (VFP, Access) non supporta listing di database multipli
try
{
using var connection = new OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
var db = connection.Database;
return string.IsNullOrEmpty(db) ? Enumerable.Empty<string>() : new[] { db };
}
catch
{
return Enumerable.Empty<string>();
}
}
private static List<string> GetTableNamesFromConnection(OleDbConnection connection)
{
var tableNames = new List<string>();
try
{
// Usa GetOleDbSchemaTable - più compatibile con VFP rispetto a GetSchema()
var restrictions = new object?[] { null, null, null, "TABLE" };
var tablesSchema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Tables, restrictions);
if (tablesSchema != null)
{
foreach (DataRow row in tablesSchema.Rows)
{
var tableName = row["TABLE_NAME"]?.ToString();
if (!string.IsNullOrEmpty(tableName))
tableNames.Add(tableName);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"GetOleDbSchemaTable Tables fallito, tentativo con GetSchema: {ex.Message}");
// Fallback a GetSchema per provider che non supportano GetOleDbSchemaTable
try
{
var tablesSchema = connection.GetSchema("Tables");
tableNames = tablesSchema.AsEnumerable()
.Where(row =>
{
var t = row["TABLE_TYPE"]?.ToString();
return t == "TABLE" || t == "BASE TABLE";
})
.Select(row => row["TABLE_NAME"]?.ToString() ?? string.Empty)
.Where(n => !string.IsNullOrEmpty(n))
.ToList();
}
catch (Exception ex2)
{
Console.WriteLine($"GetSchema Tables fallito anche: {ex2.Message}");
}
}
return tableNames.OrderBy(t => t).ToList();
}
private static List<DbColumnInfo> GetTableColumnsFromConnection(OleDbConnection connection, string tableName)
{
var columns = new List<DbColumnInfo>();
try
{
// Ottieni primary keys
var primaryKeys = GetPrimaryKeys(connection, tableName);
// Ottieni colonne via GetOleDbSchemaTable
var restrictions = new object?[] { null, null, tableName, null };
var columnsSchema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Columns, restrictions);
if (columnsSchema == null)
return columns;
// Ordina per posizione ordinale
var rows = columnsSchema.AsEnumerable()
.OrderBy(r => r.IsNull("ORDINAL_POSITION") ? 0 : Convert.ToInt32(r["ORDINAL_POSITION"]))
.ToList();
foreach (DataRow row in rows)
{
var columnName = row["COLUMN_NAME"]?.ToString();
if (string.IsNullOrEmpty(columnName))
continue;
// DATA_TYPE è un int (OleDbType enum value)
int oleDbTypeInt = row.IsNull("DATA_TYPE") ? 0 : Convert.ToInt32(row["DATA_TYPE"]);
var dataType = MapOleDbTypeToString(oleDbTypeInt);
// Formato con dimensioni
var columnSize = row.IsNull("CHARACTER_MAXIMUM_LENGTH") ? 0 : Convert.ToInt32(row["CHARACTER_MAXIMUM_LENGTH"]);
var numericPrecision = row.IsNull("NUMERIC_PRECISION") ? 0 : Convert.ToInt32(row["NUMERIC_PRECISION"]);
var numericScale = row.IsNull("NUMERIC_SCALE") ? 0 : Convert.ToInt32(row["NUMERIC_SCALE"]);
var formattedType = FormatDataType(dataType, columnSize, numericPrecision, numericScale);
bool isNullable = true;
if (!row.IsNull("IS_NULLABLE"))
isNullable = Convert.ToBoolean(row["IS_NULLABLE"]);
columns.Add(new DbColumnInfo
{
Name = columnName,
DataType = formattedType,
IsNullable = isNullable,
IsPrimaryKey = primaryKeys.Contains(columnName, StringComparer.OrdinalIgnoreCase)
});
}
}
catch (Exception ex)
{
Console.WriteLine($"Errore nel recuperare le colonne per {tableName}: {ex.Message}");
}
return columns;
}
private static HashSet<string> GetPrimaryKeys(OleDbConnection connection, string tableName)
{
var primaryKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
var restrictions = new object?[] { null, null, tableName };
var pkSchema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Primary_Keys, restrictions);
if (pkSchema != null)
{
foreach (DataRow row in pkSchema.Rows)
{
var col = row["COLUMN_NAME"]?.ToString();
if (!string.IsNullOrEmpty(col))
primaryKeys.Add(col);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Primary keys non disponibili per {tableName}: {ex.Message}");
// Fallback: prova con Indexes
try
{
var restrictions = new object?[] { null, null, null, null, tableName };
var idxSchema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Indexes, restrictions);
if (idxSchema != null)
{
foreach (DataRow row in idxSchema.Rows)
{
if (row["PRIMARY_KEY"] is bool pk && pk)
{
var col = row["COLUMN_NAME"]?.ToString();
if (!string.IsNullOrEmpty(col))
primaryKeys.Add(col);
}
}
}
}
catch { /* Se anche questo fallisce, ignora */ }
}
return primaryKeys;
}
private static string MapOleDbTypeToString(int oleDbTypeInt)
{
// Mappa OleDbType enum (int) a nome leggibile
// https://learn.microsoft.com/en-us/dotnet/api/system.data.oledb.oledbtype
return oleDbTypeInt switch
{
2 => "SmallInt",
3 => "Integer",
4 => "Single",
5 => "Double",
6 => "Currency",
7 => "Date",
8 => "VarChar", // BSTR
9 => "IDispatch",
10 => "Error",
11 => "Boolean",
12 => "Variant",
13 => "IUnknown",
14 => "Decimal",
16 => "TinyInt",
17 => "UnsignedTinyInt",
18 => "UnsignedSmallInt",
19 => "UnsignedInt",
20 => "BigInt",
21 => "UnsignedBigInt",
64 => "DateTime",
65 => "FileTime",
72 => "Guid",
128 => "Binary",
129 => "Char",
130 => "NVarChar", // WChar
131 => "Decimal", // Numeric
132 => "UserDefined",
133 => "Date",
134 => "Time",
135 => "DateTime", // DBTimeStamp
136 => "Variant", // Chapter
138 => "PropVariant",
139 => "VarNumeric",
200 => "VarChar",
201 => "LongVarChar",
202 => "NVarChar", // VarWChar
203 => "NText", // LongVarWChar
204 => "VarBinary",
205 => "Image", // LongVarBinary
_ => $"Type({oleDbTypeInt})"
};
}
private static string FormatDataType(string dataType, int columnSize, int numericPrecision, int numericScale)
{
var upper = dataType.ToUpperInvariant();
if (upper.Contains("DECIMAL") || upper.Contains("NUMERIC"))
{
if (numericPrecision > 0)
return $"{dataType}({numericPrecision},{numericScale})";
}
else if (upper.Contains("CHAR") || upper.Contains("VARCHAR") || upper.Contains("TEXT") || upper.Contains("BINARY"))
{
if (columnSize > 0 && columnSize < 8000)
return $"{dataType}({columnSize})";
else if (columnSize >= 8000)
return $"{dataType}(MAX)";
}
return dataType;
}
}
+2 -1
View File
@@ -12,5 +12,6 @@ public enum DatabaseType
Sqlite, Sqlite,
DB2, DB2,
SapHana, SapHana,
Odbc Odbc,
OleDb
} }
@@ -85,6 +85,18 @@ public interface IDatabaseManager : IDisposable
/// Ottiene il nome del campo Primary Key di una tabella specifica /// Ottiene il nome del campo Primary Key di una tabella specifica
/// </summary> /// </summary>
Task<string?> GetPrimaryKeyFieldAsync(string tableName); Task<string?> GetPrimaryKeyFieldAsync(string tableName);
/// <summary>
/// Esegue un upsert (INSERT o UPDATE) di un singolo record nella tabella specificata.
/// Se un record con lo stesso valore del campo chiave esiste già, viene aggiornato;
/// altrimenti viene inserito un nuovo record.
/// </summary>
/// <param name="tableName">Nome della tabella di destinazione</param>
/// <param name="keyField">Campo chiave per determinare se il record esiste</param>
/// <param name="keyValue">Valore del campo chiave del record</param>
/// <param name="record">Campi e valori da inserire/aggiornare</param>
/// <returns>True se l'operazione è riuscita, false altrimenti</returns>
Task<bool> UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary<string, object?> record);
} }
/// <summary> /// <summary>
+68 -3
View File
@@ -66,11 +66,19 @@ public class OdbcDatabaseManager : IDatabaseManager
using var connection = new OdbcConnection(_connectionString); using var connection = new OdbcConnection(_connectionString);
await connection.OpenAsync(); await connection.OpenAsync();
// Cambia database se specificato // Cambia database se specificato (alcuni driver come VFP non supportano ChangeDatabaseAsync)
if (!string.IsNullOrEmpty(databaseName) && databaseName != _currentDatabase) if (!string.IsNullOrEmpty(databaseName) && databaseName != _currentDatabase)
{ {
await connection.ChangeDatabaseAsync(databaseName); try
_currentDatabase = databaseName; {
await connection.ChangeDatabaseAsync(databaseName);
_currentDatabase = databaseName;
}
catch (Exception dbChangeEx)
{
Console.WriteLine($"[ODBC] ChangeDatabaseAsync non supportato dal driver ({databaseName}): {dbChangeEx.Message}");
// Continua senza cambiare database (es. driver file-based come VFP)
}
} }
using var command = new OdbcCommand(sql, connection); using var command = new OdbcCommand(sql, connection);
@@ -350,4 +358,61 @@ public class OdbcDatabaseManager : IDatabaseManager
{ {
// Nessuna risorsa da rilasciare per ODBC diretto // Nessuna risorsa da rilasciare per ODBC diretto
} }
/// <inheritdoc />
public async Task<bool> UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary<string, object?> record)
{
try
{
using var connection = new OdbcConnection(_connectionString);
await connection.OpenAsync();
// Controlla se il record esiste già (ODBC usa ? come placeholder)
using var checkCmd = new OdbcCommand($"SELECT COUNT(*) FROM {tableName} WHERE [{keyField}] = ?", connection);
checkCmd.Parameters.Add(new OdbcParameter { Value = keyValue ?? DBNull.Value });
var countResult = await checkCmd.ExecuteScalarAsync();
bool exists = Convert.ToInt64(countResult ?? 0L) > 0;
if (exists)
{
// UPDATE
var fields = record.Keys.ToList();
var setClauses = fields.Select(f => $"[{f}] = ?").ToList();
var updateSql = $"UPDATE {tableName} SET {string.Join(", ", setClauses)} WHERE [{keyField}] = ?";
using var updateCmd = new OdbcCommand(updateSql, connection);
foreach (var f in fields)
updateCmd.Parameters.Add(new OdbcParameter { Value = record[f] ?? DBNull.Value });
// Parametro per la WHERE
updateCmd.Parameters.Add(new OdbcParameter { Value = keyValue ?? DBNull.Value });
await updateCmd.ExecuteNonQueryAsync();
}
else
{
// INSERT
var fields = record.Keys.ToList();
var fieldNames = string.Join(", ", fields.Select(f => $"[{f}]"));
var paramPlaceholders = string.Join(", ", fields.Select(_ => "?"));
var insertSql = $"INSERT INTO {tableName} ({fieldNames}) VALUES ({paramPlaceholders})";
using var insertCmd = new OdbcCommand(insertSql, connection);
foreach (var f in fields)
insertCmd.Parameters.Add(new OdbcParameter { Value = record[f] ?? DBNull.Value });
await insertCmd.ExecuteNonQueryAsync();
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Errore nell'upsert ODBC in {tableName}: {ex.Message}");
return false;
}
}
} }
+379
View File
@@ -0,0 +1,379 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.OleDb;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using DataConnection.EF.SchemaProviders;
using DataConnection.Interfaces;
namespace DataConnection.DB;
/// <summary>
/// Database manager per connessioni OLE DB dirette (es. Visual FoxPro, Access, Jet)
/// Nota: i driver OLE DB come VFPOLEDB.1 sono 32-bit only — pubblicare con --runtime win-x86 se necessario
/// </summary>
public class OleDbDatabaseManager : IDatabaseManager
{
private readonly string _connectionString;
private readonly OleDbSchemaProvider _schemaProvider;
public OleDbDatabaseManager(string connectionString)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
throw new PlatformNotSupportedException("OLE DB è supportato solo su Windows.");
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaProvider = new OleDbSchemaProvider();
}
public async Task<bool> TestConnectionAsync()
{
try
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
return true;
}
catch
{
return false;
}
}
public Task<IEnumerable<T>> GetAsync<T>(
Expression<Func<T, bool>>? filter = null,
Func<IQueryable<T>, IOrderedQueryable<T>>? orderBy = null,
string includeProperties = "",
int? skip = null,
int? take = null) where T : class
{
throw new NotSupportedException("GetAsync<T> con espressioni LINQ non è supportato per OLE DB. Usare ExecuteRawQueryAsync.");
}
public Task<T?> GetByIdAsync<T>(object id) where T : class
{
throw new NotSupportedException("GetByIdAsync<T> non è supportato per OLE DB. Usare ExecuteRawQueryAsync con clausola WHERE.");
}
public Task<IEnumerable<T>> ExecuteQueryAsync<T>(string sql, params object[] parameters) where T : class
{
throw new NotSupportedException("ExecuteQueryAsync<T> non è supportato per OLE DB. Usare ExecuteRawQueryAsync.");
}
public async Task<List<Dictionary<string, object>>> ExecuteRawQueryAsync(string sql, string databaseName = "", params object[] parameters)
{
var results = new List<Dictionary<string, object>>();
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
// OLE DB file-based (VFP, Access) non supporta ChangeDatabaseAsync — il database è nel Data Source
// Ignoriamo databaseName per questa tipologia di provider
using var command = new OleDbCommand(sql, connection);
if (parameters != null && parameters.Length > 0)
{
for (int i = 0; i < parameters.Length; i++)
{
command.Parameters.Add(new OleDbParameter($"@p{i}", parameters[i] ?? DBNull.Value));
}
}
using var reader = await Task.Run(() => command.ExecuteReader());
while (reader.Read())
{
var row = new Dictionary<string, object>();
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<int> ExecuteCommandAsync(string sql, params object[] parameters)
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(sql, connection);
if (parameters != null && parameters.Length > 0)
{
for (int i = 0; i < parameters.Length; i++)
{
command.Parameters.Add(new OleDbParameter($"@p{i}", parameters[i] ?? DBNull.Value));
}
}
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<List<string>> GetAvailableDatabasesAsync()
{
var databases = await _schemaProvider.GetAvailableDatabasesAsync(_connectionString);
return databases.ToList();
}
public async Task ChangeDatabaseAsync(string databaseName)
{
// I provider OLE DB file-based (VFP, Access) non supportano il cambio di database a runtime
// Il Data Source nella connection string definisce il database
Console.WriteLine($"[OleDb] ChangeDatabaseAsync ignorato per provider file-based (database: {databaseName})");
await Task.CompletedTask;
}
public async Task<IDictionary<string, IEnumerable<DbColumnInfo>>> GetDatabaseSchemaAsync()
{
return await _schemaProvider.GetDatabaseSchemaAsync(_connectionString);
}
public async Task<IEnumerable<string>> GetTableNamesAsync()
{
return await _schemaProvider.GetTableNamesAsync(_connectionString);
}
public async Task<IEnumerable<DbColumnInfo>> GetTableSchemaAsync(string tableName)
{
return await _schemaProvider.GetTableSchemaAsync(_connectionString, tableName);
}
public async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsAsync(string tableName)
{
var query = $"SELECT * FROM {tableName}";
return await ExecuteRawQueryAsync(query);
}
public async Task<string?> 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<IEnumerable<IDictionary<string, object?>>> ExecuteQueryAsync(string query, int? maxRows = null)
{
var results = new List<IDictionary<string, object?>>();
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
var commandText = maxRows.HasValue ? WrapQueryWithLimit(query, maxRows.Value) : query;
using var command = new OleDbCommand(commandText, connection);
using var reader = await Task.Run(() => command.ExecuteReader());
while (reader.Read())
{
var row = new Dictionary<string, object?>();
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<int> ExecuteNonQueryAsync(string query)
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<object?> ExecuteScalarAsync(string query)
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
return await Task.Run(() => command.ExecuteScalar());
}
public async Task<int> InsertAsync(string tableName, IDictionary<string, object?> data)
{
var columns = string.Join(", ", data.Keys.Select(k => $"[{k}]"));
var parameters = string.Join(", ", data.Keys.Select(_ => "?"));
var query = $"INSERT INTO {tableName} ({columns}) VALUES ({parameters})";
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
foreach (var value in data.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<int> UpdateAsync(string tableName, IDictionary<string, object?> data, IDictionary<string, object?> 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 OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
foreach (var value in data.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
foreach (var value in whereClause.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<int> DeleteAsync(string tableName, IDictionary<string, object?> whereClause)
{
var whereConditions = string.Join(" AND ", whereClause.Keys.Select(k => $"[{k}] = ?"));
var query = $"DELETE FROM {tableName} WHERE {whereConditions}";
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
foreach (var value in whereClause.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<int> BulkInsertAsync(string tableName, IEnumerable<IDictionary<string, object?>> dataList)
{
int totalInserted = 0;
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
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(_ => "?"));
var query = $"INSERT INTO {tableName} ({columns}) VALUES ({parameters})";
using var command = new OleDbCommand(query, connection, transaction);
foreach (var value in data.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
totalInserted += await Task.Run(() => command.ExecuteNonQuery());
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
return totalInserted;
}
public async Task<bool> UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary<string, object?> record)
{
try
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var checkCmd = new OleDbCommand($"SELECT COUNT(*) FROM {tableName} WHERE [{keyField}] = ?", connection);
checkCmd.Parameters.Add(new OleDbParameter { Value = keyValue ?? DBNull.Value });
var countResult = await Task.Run(() => checkCmd.ExecuteScalar());
bool exists = Convert.ToInt64(countResult ?? 0L) > 0;
if (exists)
{
var fields = record.Keys.ToList();
var setClauses = fields.Select(f => $"[{f}] = ?").ToList();
var updateSql = $"UPDATE {tableName} SET {string.Join(", ", setClauses)} WHERE [{keyField}] = ?";
using var updateCmd = new OleDbCommand(updateSql, connection);
foreach (var f in fields)
updateCmd.Parameters.Add(new OleDbParameter { Value = record[f] ?? DBNull.Value });
updateCmd.Parameters.Add(new OleDbParameter { Value = keyValue ?? DBNull.Value });
await Task.Run(() => updateCmd.ExecuteNonQuery());
}
else
{
var fields = record.Keys.ToList();
var fieldNames = string.Join(", ", fields.Select(f => $"[{f}]"));
var paramPlaceholders = string.Join(", ", fields.Select(_ => "?"));
var insertSql = $"INSERT INTO {tableName} ({fieldNames}) VALUES ({paramPlaceholders})";
using var insertCmd = new OleDbCommand(insertSql, connection);
foreach (var f in fields)
insertCmd.Parameters.Add(new OleDbParameter { Value = record[f] ?? DBNull.Value });
await Task.Run(() => insertCmd.ExecuteNonQuery());
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Errore nell'upsert OLE DB in {tableName}: {ex.Message}");
return false;
}
}
/// <summary>
/// VFP e la maggior parte dei provider OLE DB supportano SELECT TOP N (SQL Server style)
/// </summary>
private static string WrapQueryWithLimit(string query, int maxRows)
{
var upperQuery = query.Trim().ToUpperInvariant();
if (upperQuery.Contains("LIMIT ") || upperQuery.Contains("TOP "))
return query;
if (upperQuery.StartsWith("SELECT "))
return query.Insert(7, $"TOP {maxRows} ");
return $"{query} LIMIT {maxRows}";
}
public void Dispose()
{
// Nessuna risorsa da rilasciare
}
}
+2
View File
@@ -15,7 +15,9 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.5" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.5" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.3" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.3" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
<PackageReference Include="System.Data.Odbc" Version="9.0.3" /> <PackageReference Include="System.Data.Odbc" Version="9.0.3" />
<PackageReference Include="System.Data.OleDb" Version="9.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -41,6 +41,11 @@ namespace DataConnection.REST.Configuration
/// </summary> /// </summary>
public bool IgnoreSslErrors { get; set; } = false; public bool IgnoreSslErrors { get; set; } = false;
// Add other relevant configuration properties (e.g., OAuth settings, specific headers) /// <summary>
/// Salesforce OAuth2 grant type. Default: Password (retrocompatibile).
/// ClientCredentials = server-to-server, senza utente.
/// </summary>
public CredentialManager.Models.SalesforceGrantType SalesforceGrantType { get; set; }
= CredentialManager.Models.SalesforceGrantType.Password;
} }
} }
File diff suppressed because it is too large Load Diff
+28
View File
@@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CredentialManager", "Creden
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components", "Components\Components.csproj", "{B5114CAC-3E03-4150-B93C-652882F66CB7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components", "Components\Components.csproj", "{B5114CAC-3E03-4150-B93C-652882F66CB7}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MachineGuard", "MachineGuard\MachineGuard.csproj", "{AFF3AD52-0356-4879-A0C8-67819611445A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MachineGuardSetup", "MachineGuardSetup\MachineGuardSetup.csproj", "{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -69,6 +73,30 @@ Global
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x64.Build.0 = Release|Any CPU {B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x64.Build.0 = Release|Any CPU
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.ActiveCfg = Release|Any CPU {B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.ActiveCfg = Release|Any CPU
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.Build.0 = Release|Any CPU {B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x64.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x64.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x86.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x86.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|Any CPU.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x64.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x64.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x86.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x86.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x64.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x64.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x86.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x86.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|Any CPU.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x64.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x64.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x86.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -286,6 +286,8 @@ public class ScheduledJobService : BackgroundService
executionHistory.EndTime = DateTime.Now; executionHistory.EndTime = DateTime.Now;
executionHistory.Status = "failed"; executionHistory.Status = "failed";
executionHistory.Message = $"Errore durante l'esecuzione automatica: {ex.Message}"; executionHistory.Message = $"Errore durante l'esecuzione automatica: {ex.Message}";
// Memorizza il dettaglio completo (stack trace) solo per scopi diagnostici;
// la UI in produzione ne mostrerà una versione sanitizzata senza percorsi di file.
executionHistory.ErrorDetails = ex.ToString(); executionHistory.ErrorDetails = ex.ToString();
await scheduleService.UpdateExecutionHistoryAsync(executionHistory); await scheduleService.UpdateExecutionHistoryAsync(executionHistory);
} }
-12
View File
@@ -1,12 +0,0 @@
namespace Data_Coupler.Data;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
@@ -1,19 +0,0 @@
namespace Data_Coupler.Data;
public class WeatherForecastService
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
public Task<WeatherForecast[]> GetForecastAsync(DateOnly startDate)
{
return Task.FromResult(Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
}).ToArray());
}
}
+53
View File
@@ -7,12 +7,20 @@
<!-- Version is now automatically calculated by MinVer from git tags --> <!-- Version is now automatically calculated by MinVer from git tags -->
<MinVerTagPrefix>v</MinVerTagPrefix> <MinVerTagPrefix>v</MinVerTagPrefix>
<MinVerVerbosity>detailed</MinVerVerbosity> <MinVerVerbosity>detailed</MinVerVerbosity>
<!-- Disabilita trimming per compatibilità Blazor Server -->
<PublishTrimmed>false</PublishTrimmed>
<!-- Abilita PublishSingleFile per deployment semplificato -->
<PublishSingleFile>true</PublishSingleFile>
<!-- Abilita ReadyToRun per migliori performance di avvio -->
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DataConnection\DataConnection.csproj" /> <ProjectReference Include="..\DataConnection\DataConnection.csproj" />
<ProjectReference Include="..\CredentialManager\CredentialManager.csproj" /> <ProjectReference Include="..\CredentialManager\CredentialManager.csproj" />
<ProjectReference Include="..\Components\Components.csproj" /> <ProjectReference Include="..\Components\Components.csproj" />
<ProjectReference Include="..\MachineGuard\MachineGuard.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -35,4 +43,49 @@
</Content> </Content>
</ItemGroup> </ItemGroup>
<!-- Target per generare automaticamente version.json prima del build -->
<!-- SOLO per sviluppo locale, NON in CI/CD (il workflow lo genera prima del Docker build) -->
<Target Name="GenerateVersionJson" BeforeTargets="BeforeBuild;BeforeRebuild"
Condition="'$(ContinuousIntegrationBuild)' != 'true' AND '$(SkipVersionGeneration)' != 'true'">
<!-- Esegui git per ottenere il tag più recente e informazioni commit -->
<Exec Command="git describe --tags --abbrev=0 2>nul" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitLatestTag" />
</Exec>
<Exec Command="git rev-parse --short HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitSha" />
</Exec>
<Exec Command="git rev-parse --abbrev-ref HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitBranch" />
</Exec>
<!-- Estrai la versione dal tag (rimuovi il prefisso 'v') -->
<PropertyGroup>
<ActualVersion Condition="'$(GitLatestTag)' != '' and $(GitLatestTag.StartsWith('v'))">$(GitLatestTag.Substring(1))</ActualVersion>
<ActualVersion Condition="'$(GitLatestTag)' != '' and !$(GitLatestTag.StartsWith('v'))">$(GitLatestTag)</ActualVersion>
<ActualVersion Condition="'$(GitLatestTag)' == ''">1.0.0-dev</ActualVersion>
<ActualCommitSha Condition="'$(GitCommitSha)' != ''">$(GitCommitSha)</ActualCommitSha>
<ActualCommitSha Condition="'$(GitCommitSha)' == ''">unknown</ActualCommitSha>
<ActualBranch Condition="'$(GitBranch)' != ''">$(GitBranch)</ActualBranch>
<ActualBranch Condition="'$(GitBranch)' == ''">local</ActualBranch>
</PropertyGroup>
<!-- Genera il contenuto JSON -->
<PropertyGroup>
<FinalVersionJsonContent>
{
"version": "$(ActualVersion)",
"commitSha": "$(ActualCommitSha)",
"branch": "$(ActualBranch)",
"buildDate": "$([System.DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))",
"buildEnvironment": "Development"
}
</FinalVersionJsonContent>
</PropertyGroup>
<!-- Scrivi il file version.json -->
<WriteLinesToFile File="wwwroot\version.json" Lines="$(FinalVersionJsonContent)" Overwrite="true" />
<Message Text="Generated version.json with version $(ActualVersion) from git tag (local dev build)" Importance="high" />
</Target>
</Project> </Project>
@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using CredentialManager.Models;
using DataConnection.Interfaces;
using Data_Coupler.Services;
namespace Data_Coupler.Pages;
/// <summary>
/// Partial class per la gestione di un database come destinazione dati.
/// Consente di selezionare un database di destinazione, scoprirne le tabelle
/// e configurare il mapping dei campi verso la tabella di destinazione.
/// </summary>
public partial class DataCoupler : ComponentBase
{
// ===== PROPRIETÀ TIPO DESTINAZIONE =====
/// <summary>
/// Tipo di destinazione: "rest" (default) oppure "database".
/// Controlla quale sezione UI viene mostrata nella card di destra.
/// </summary>
protected string selectedDestinationType = "rest";
// ===== PROPRIETÀ DATABASE DESTINAZIONE =====
/// <summary>Credenziale database selezionata come destinazione</summary>
protected string selectedDestinationDatabaseCredential = "";
/// <summary>Stato connessione in corso</summary>
protected bool isConnectingDestinationDatabase = false;
/// <summary>Database di destinazione connesso con successo</summary>
protected bool isDestinationDatabaseConnected = false;
/// <summary>Messaggio di errore connessione database destinazione</summary>
protected string destinationDatabaseErrorMessage = "";
/// <summary>Nomi delle tabelle disponibili nel database di destinazione</summary>
protected List<string> destAvailableTableNames = new();
/// <summary>Schema dettagliato per tabella di destinazione (caricato on-demand)</summary>
protected Dictionary<string, IEnumerable<DbColumnInfo>> destDatabaseTables = new();
/// <summary>Tabella di destinazione selezionata</summary>
protected string selectedDestinationTable = "";
/// <summary>Termine di ricerca per filtrare le tabelle di destinazione</summary>
protected string destDatabaseSearchTerm = "";
/// <summary>Database manager per il database di destinazione</summary>
protected IDatabaseManager? currentDestinationDatabaseManager = null;
// ===== METODI DATABASE DESTINAZIONE =====
/// <summary>
/// Gestisce il cambio del tipo di destinazione (rest / database)
/// </summary>
protected void OnDestinationTypeChanged(ChangeEventArgs e)
{
var newType = e.Value?.ToString() ?? "rest";
if (newType == selectedDestinationType)
return;
selectedDestinationType = newType;
// Reset lo stato della destinazione precedente
ResetDestinationState();
if (newType == "database")
{
ResetDestinationDatabaseState();
}
// Pulisce i mapping configurati (dipendono dal tipo di destinazione)
ClearAllMappings();
StateHasChanged();
}
/// <summary>
/// Gestisce il cambio della credenziale database di destinazione
/// </summary>
protected void OnDestinationDatabaseCredentialChanged(ChangeEventArgs e)
{
selectedDestinationDatabaseCredential = e.Value?.ToString() ?? "";
ResetDestinationDatabaseState();
}
/// <summary>
/// Resetta lo stato del database di destinazione
/// </summary>
protected void ResetDestinationDatabaseState()
{
isDestinationDatabaseConnected = false;
destAvailableTableNames.Clear();
destDatabaseTables.Clear();
selectedDestinationTable = "";
destDatabaseSearchTerm = "";
destinationDatabaseErrorMessage = "";
// Rilascia il database manager
if (currentDestinationDatabaseManager != null)
{
try { currentDestinationDatabaseManager.Dispose(); } catch { /* ignore */ }
currentDestinationDatabaseManager = null;
}
}
/// <summary>
/// Si connette al database di destinazione e carica le tabelle disponibili
/// </summary>
protected async Task ConnectToDestinationDatabase()
{
if (string.IsNullOrEmpty(selectedDestinationDatabaseCredential))
return;
isConnectingDestinationDatabase = true;
destinationDatabaseErrorMessage = "";
try
{
// Verifica credenziale
var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDestinationDatabaseCredential);
if (credential == null)
{
destinationDatabaseErrorMessage = "Credenziale database non trovata";
return;
}
// Crea il database manager
currentDestinationDatabaseManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDestinationDatabaseCredential);
// Verifica la connessione
var canConnect = await currentDestinationDatabaseManager.TestConnectionAsync();
if (!canConnect)
{
destinationDatabaseErrorMessage = "Impossibile connettersi al database di destinazione. Verificare le credenziali.";
currentDestinationDatabaseManager.Dispose();
currentDestinationDatabaseManager = null;
return;
}
// Carica i nomi delle tabelle
var tableNames = await currentDestinationDatabaseManager.GetTableNamesAsync();
destAvailableTableNames = tableNames.OrderBy(t => t).ToList();
isDestinationDatabaseConnected = true;
Logger.LogInformation("Database destinazione connesso: {Credential}, {Count} tabelle trovate",
selectedDestinationDatabaseCredential, destAvailableTableNames.Count);
}
catch (Exception ex)
{
destinationDatabaseErrorMessage = $"Errore di connessione: {ex.Message}";
Logger.LogError(ex, "Errore nella connessione al database destinazione: {Credential}", selectedDestinationDatabaseCredential);
if (currentDestinationDatabaseManager != null)
{
try { currentDestinationDatabaseManager.Dispose(); } catch { /* ignore */ }
currentDestinationDatabaseManager = null;
}
}
finally
{
isConnectingDestinationDatabase = false;
StateHasChanged();
}
}
/// <summary>
/// Seleziona la tabella di destinazione e carica il suo schema
/// </summary>
protected async Task SelectDestinationTable(string tableName)
{
selectedDestinationTable = tableName;
// Carica lo schema della tabella se non è già disponibile
if (currentDestinationDatabaseManager != null && !destDatabaseTables.ContainsKey(tableName))
{
try
{
Logger.LogInformation("Caricamento schema tabella destinazione: {TableName}", tableName);
var schema = await currentDestinationDatabaseManager.GetTableSchemaAsync(tableName);
destDatabaseTables[tableName] = schema;
Logger.LogInformation("Schema tabella destinazione caricato: {ColumnCount} colonne", schema.Count());
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nel caricamento schema tabella destinazione {TableName}", tableName);
destinationDatabaseErrorMessage = $"Errore nel caricamento schema: {ex.Message}";
}
}
// Pulisce i mapping quando si cambia tabella
ClearAllMappings();
StateHasChanged();
}
/// <summary>
/// Restituisce la lista filtrata delle tabelle di destinazione
/// </summary>
protected IEnumerable<string> GetFilteredDestinationTables()
{
if (string.IsNullOrEmpty(destDatabaseSearchTerm))
return destAvailableTableNames;
return destAvailableTableNames
.Where(t => t.Contains(destDatabaseSearchTerm, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Aggiorna il termine di ricerca per le tabelle di destinazione
/// </summary>
protected void FilterDestinationTables(ChangeEventArgs e)
{
destDatabaseSearchTerm = e.Value?.ToString() ?? "";
StateHasChanged();
}
/// <summary>
/// Pulisce il termine di ricerca per le tabelle di destinazione
/// </summary>
protected void ClearDestinationTableSearch()
{
destDatabaseSearchTerm = "";
StateHasChanged();
}
/// <summary>
/// Indica se la configurazione corrente è pronta per il trasferimento verso database
/// </summary>
protected bool IsDestinationDatabaseReady =>
isDestinationDatabaseConnected &&
!string.IsNullOrEmpty(selectedDestinationTable) &&
currentDestinationDatabaseManager != null;
}
@@ -70,7 +70,6 @@ public partial class DataCoupler : ComponentBase
/// <summary> /// <summary>
/// Verifica se la credenziale database selezionata è di tipo ODBC /// Verifica se la credenziale database selezionata è di tipo ODBC
/// </summary> /// </summary>
/// <returns>True se la credenziale è ODBC, altrimenti False</returns>
protected bool IsOdbcConnection() protected bool IsOdbcConnection()
{ {
if (string.IsNullOrEmpty(selectedDatabaseCredential)) if (string.IsNullOrEmpty(selectedDatabaseCredential))
@@ -80,6 +79,18 @@ public partial class DataCoupler : ComponentBase
return credential?.DatabaseType == DatabaseType.Odbc; return credential?.DatabaseType == DatabaseType.Odbc;
} }
/// <summary>
/// Verifica se la credenziale database selezionata è di tipo OLE DB
/// </summary>
protected bool IsOleDbConnection()
{
if (string.IsNullOrEmpty(selectedDatabaseCredential))
return false;
var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential);
return credential?.DatabaseType == DatabaseType.OleDb;
}
/// <summary> /// <summary>
/// Gestisce il cambio di credenziale database selezionata /// Gestisce il cambio di credenziale database selezionata
/// </summary> /// </summary>
@@ -621,11 +632,12 @@ public partial class DataCoupler : ComponentBase
return; return;
} }
// Per ODBC, crea un database manager temporaneo se non esiste // Per ODBC e OLE DB, crea un database manager temporaneo se non esiste
var managerToUse = currentDatabaseManager; var managerToUse = currentDatabaseManager;
if (managerToUse == null && IsOdbcConnection()) if (managerToUse == null && (IsOdbcConnection() || IsOleDbConnection()))
{ {
Logger.LogInformation("Creando database manager temporaneo per validazione query ODBC"); Logger.LogInformation("Creando database manager temporaneo per validazione query {Type}",
IsOdbcConnection() ? "ODBC" : "OLE DB");
tempManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDatabaseCredential); tempManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDatabaseCredential);
managerToUse = tempManager; managerToUse = tempManager;
} }
@@ -661,8 +673,8 @@ public partial class DataCoupler : ComponentBase
Logger.LogInformation("Query validata con successo: {ColumnCount} colonne", queryColumns.Count); Logger.LogInformation("Query validata con successo: {ColumnCount} colonne", queryColumns.Count);
// Per ODBC, salva il manager se non era già presente // Per ODBC e OLE DB, salva il manager temporaneo per riuso
if (IsOdbcConnection() && currentDatabaseManager == null && tempManager != null) if ((IsOdbcConnection() || IsOleDbConnection()) && currentDatabaseManager == null && tempManager != null)
{ {
currentDatabaseManager = tempManager; currentDatabaseManager = tempManager;
tempManager = null; // Non distruggerlo nel finally tempManager = null; // Non distruggerlo nel finally
@@ -747,6 +759,7 @@ public partial class DataCoupler : ComponentBase
return databaseType switch return databaseType switch
{ {
DatabaseType.SqlServer => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery", DatabaseType.SqlServer => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery",
DatabaseType.OleDb => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery",
DatabaseType.Oracle => $"SELECT * FROM ({baseQuery}) WHERE ROWNUM <= {limit}", DatabaseType.Oracle => $"SELECT * FROM ({baseQuery}) WHERE ROWNUM <= {limit}",
DatabaseType.MySql => $"{baseQuery} LIMIT {limit}", DatabaseType.MySql => $"{baseQuery} LIMIT {limit}",
DatabaseType.PostgreSql => $"{baseQuery} LIMIT {limit}", DatabaseType.PostgreSql => $"{baseQuery} LIMIT {limit}",
@@ -140,12 +140,31 @@ public partial class DataCoupler : ComponentBase
Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType); Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType);
// Discovery delle entità disponibili usando il metodo batch ottimizzato // Avvia entrambe le discovery in parallelo:
Logger.LogInformation("Iniziando discovery batch delle entità REST..."); // - DiscoverEntitySummariesAsync è veloce (1 API call) → sblocca la UI subito
restEntities = await currentRestDiscovery.DiscoverEntitySummariesAsync(); // - DiscoverEntitiesAsync è pesante (batch describe) → completa in background
isRestConnected = true; 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<RestEntityInfo>();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using CredentialManager.Models;
using DataConnection.REST.Interfaces;
using DataConnection.REST.Models;
using Data_Coupler.Services;
namespace Data_Coupler.Pages;
/// <summary>
/// Partial class per la gestione di Salesforce come sorgente dati.
/// Consente di autenticarsi a Salesforce, scoprire gli SObject disponibili
/// e selezionare un'entità da cui estrarre i dati da trasferire.
/// </summary>
public partial class DataCoupler : ComponentBase
{
// ===== PROPRIETÀ SALESFORCE SOURCE =====
/// <summary>Credenziali Salesforce disponibili come sorgente</summary>
protected List<RestApiCredential> salesforceSourceCredentials = new();
/// <summary>Credenziale Salesforce selezionata come sorgente</summary>
protected string selectedSalesforceSourceCredential = "";
/// <summary>Stato connessione in corso</summary>
protected bool isConnectingSalesforceSource = false;
/// <summary>Salesforce source connessa con successo</summary>
protected bool isSalesforceSourceConnected = false;
/// <summary>Messaggio di errore connessione Salesforce source</summary>
protected string salesforceSourceErrorMessage = "";
/// <summary>Lista degli SObject Salesforce disponibili (summaries)</summary>
protected List<RestEntitySummary> salesforceSourceEntities = new();
/// <summary>SObject Salesforce selezionato come sorgente</summary>
protected RestEntitySummary? selectedSalesforceSourceEntity = null;
/// <summary>Dettagli (campi) dell'SObject selezionato</summary>
protected RestEntityInfo? salesforceSourceEntityDetails = null;
/// <summary>Termine di ricerca per filtrare gli SObject</summary>
protected string salesforceSourceSearchTerm = "";
/// <summary>Client REST per le operazioni Salesforce source</summary>
protected IRestServiceClient? currentSalesforceSourceClient = null;
/// <summary>Discovery metadata per Salesforce source</summary>
protected IRestMetadataDiscovery? currentSalesforceSourceDiscovery = null;
// ===== METODI SALESFORCE SOURCE =====
/// <summary>
/// Carica le credenziali di tipo Salesforce per usarle come sorgente
/// </summary>
protected async Task LoadSalesforceSourceCredentials()
{
try
{
var allCreds = await CredentialService.GetAllRestApiCredentialsAsync();
salesforceSourceCredentials = allCreds
.Where(c => c.ServiceType == RestServiceType.Salesforce)
.ToList();
Logger.LogInformation("Caricate {Count} credenziali Salesforce per uso come sorgente", salesforceSourceCredentials.Count);
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nel caricamento delle credenziali Salesforce source");
}
}
/// <summary>
/// Gestisce il cambio di credenziale Salesforce source
/// </summary>
protected void OnSalesforceSourceCredentialChanged(ChangeEventArgs e)
{
var newCredential = e.Value?.ToString() ?? "";
// Pulisce la cache se si cambia credenziale
if (!string.IsNullOrEmpty(selectedSalesforceSourceCredential) && selectedSalesforceSourceCredential != newCredential)
{
try { ConnectionFactory.ClearRestClientCache(selectedSalesforceSourceCredential); } catch { /* ignore */ }
}
selectedSalesforceSourceCredential = newCredential;
ResetSalesforceSourceState();
}
/// <summary>
/// Resetta lo stato della connessione Salesforce source
/// </summary>
protected void ResetSalesforceSourceState()
{
isSalesforceSourceConnected = false;
salesforceSourceEntities.Clear();
selectedSalesforceSourceEntity = null;
salesforceSourceEntityDetails = null;
salesforceSourceSearchTerm = "";
salesforceSourceErrorMessage = "";
currentSalesforceSourceDiscovery = null;
currentSalesforceSourceClient = null;
}
/// <summary>
/// Si connette a Salesforce come sorgente e scopre gli SObject disponibili
/// </summary>
protected async Task ConnectToSalesforceSource()
{
if (string.IsNullOrEmpty(selectedSalesforceSourceCredential))
return;
isConnectingSalesforceSource = true;
salesforceSourceErrorMessage = "";
try
{
// Verifica la credenziale
var credential = salesforceSourceCredentials.FirstOrDefault(c => c.Name == selectedSalesforceSourceCredential);
if (credential == null)
{
salesforceSourceErrorMessage = "Credenziale Salesforce non trovata";
return;
}
// Crea i client usando il factory
currentSalesforceSourceClient = await ConnectionFactory.CreateRestServiceClientAsync(selectedSalesforceSourceCredential);
currentSalesforceSourceDiscovery = await ConnectionFactory.CreateRestMetadataDiscoveryAsync(selectedSalesforceSourceCredential);
// Autenticazione
Logger.LogInformation("Avvio autenticazione Salesforce source: {Credential}", selectedSalesforceSourceCredential);
var authResult = await currentSalesforceSourceClient.AuthenticateAsync();
if (!authResult)
{
salesforceSourceErrorMessage = "Autenticazione Salesforce fallita. Verificare le credenziali.";
currentSalesforceSourceClient = null;
currentSalesforceSourceDiscovery = null;
return;
}
// Discovery parallela: summaries veloci → UI interattiva subito; dettagli completi in background
Logger.LogInformation("Avvio discovery parallela SObject Salesforce source...");
var summariesTask = currentSalesforceSourceDiscovery.DiscoverEntitySummariesAsync();
var entitiesTask = currentSalesforceSourceDiscovery.DiscoverEntitiesAsync();
// Le summaries sono rapide (1 sola API call) → rendiamo la UI interattiva subito
salesforceSourceEntities = await summariesTask;
isSalesforceSourceConnected = true;
StateHasChanged();
Logger.LogInformation("SObject summaries caricate: {Count} entità disponibili", salesforceSourceEntities.Count);
// I dettagli completano in background (non bloccano la UI)
try
{
await entitiesTask;
Logger.LogInformation("Discovery dettagli SObject completata in background");
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Impossibile completare la discovery dettagli SObject (non critico)");
}
}
catch (Exception ex)
{
salesforceSourceErrorMessage = $"Errore di connessione: {ex.Message}";
Logger.LogError(ex, "Errore nella connessione a Salesforce source: {Credential}", selectedSalesforceSourceCredential);
currentSalesforceSourceClient = null;
currentSalesforceSourceDiscovery = null;
}
finally
{
isConnectingSalesforceSource = false;
StateHasChanged();
}
}
/// <summary>
/// Seleziona un SObject Salesforce come sorgente dati e carica i suoi campi
/// </summary>
protected async Task SelectSalesforceSourceEntity(RestEntitySummary entity)
{
selectedSalesforceSourceEntity = entity;
salesforceSourceEntityDetails = null;
// Carica i dettagli dei campi dell'SObject selezionato
if (currentSalesforceSourceDiscovery != null)
{
try
{
Logger.LogInformation("Caricamento dettagli SObject sorgente: {EntityName}", entity.Name);
salesforceSourceEntityDetails = await currentSalesforceSourceDiscovery.DiscoverEntityDetailsAsync(entity.Name);
Logger.LogInformation("Dettagli SObject caricati: {FieldCount} campi disponibili",
salesforceSourceEntityDetails?.Properties.Count ?? 0);
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nel caricamento dettagli SObject {EntityName}", entity.Name);
salesforceSourceErrorMessage = $"Errore nel caricamento dei campi: {ex.Message}";
}
}
// Pulisce i mapping esistenti quando si cambia entità
ClearAllMappings();
StateHasChanged();
}
/// <summary>
/// Restituisce la lista filtrata degli SObject in base al termine di ricerca
/// </summary>
protected IEnumerable<RestEntitySummary> GetFilteredSalesforceSourceEntities()
{
if (string.IsNullOrEmpty(salesforceSourceSearchTerm))
return salesforceSourceEntities;
return salesforceSourceEntities
.Where(e => e.Name.Contains(salesforceSourceSearchTerm, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrEmpty(e.Label) && e.Label.Contains(salesforceSourceSearchTerm, StringComparison.OrdinalIgnoreCase)));
}
/// <summary>
/// Aggiorna il termine di ricerca per gli SObject sorgente
/// </summary>
protected void FilterSalesforceSourceEntities(ChangeEventArgs e)
{
salesforceSourceSearchTerm = e.Value?.ToString() ?? "";
StateHasChanged();
}
/// <summary>
/// Pulisce il termine di ricerca per gli SObject sorgente
/// </summary>
protected void ClearSalesforceSourceSearch()
{
salesforceSourceSearchTerm = "";
StateHasChanged();
}
/// <summary>
/// Estrae tutti i record dall'SObject Salesforce selezionato usando le mappature campi configurate
/// </summary>
protected async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsFromSalesforceSource()
{
if (currentSalesforceSourceClient == null || selectedSalesforceSourceEntity == null)
return new List<Dictionary<string, object>>();
if (!(currentSalesforceSourceClient is DataConnection.REST.Implementations.SalesforceServiceClient sfClient))
{
Logger.LogError("Il client Salesforce source non è un'istanza di SalesforceServiceClient");
return new List<Dictionary<string, object>>();
}
try
{
// Determina i campi da estrarre (solo quelli mappati + campo chiave)
var fieldsToExtract = new List<string>();
// Aggiungi i campi sorgente dal mapping
fieldsToExtract.AddRange(fieldMappings.Keys);
// Aggiungi il campo chiave sorgente se configurato
if (!string.IsNullOrEmpty(sourceKeyField) && !fieldsToExtract.Contains(sourceKeyField))
fieldsToExtract.Add(sourceKeyField);
// Aggiungi i campi usati nelle External ID Relationships (se presenti e destinazione è REST)
foreach (var rel in externalIdRelationships)
{
if (!string.IsNullOrEmpty(rel.SourceField) && !fieldsToExtract.Contains(rel.SourceField))
fieldsToExtract.Add(rel.SourceField);
}
// Se nessun campo è specificato, estrae tutto
var fields = fieldsToExtract.Any() ? fieldsToExtract : null;
Logger.LogInformation("Estrazione dati da Salesforce SObject: {EntityName}, Campi: {Fields}",
selectedSalesforceSourceEntity.Name,
fields != null ? string.Join(", ", fields) : "tutti");
var records = await sfClient.ExtractAllEntitiesAsync(
selectedSalesforceSourceEntity.Name,
fields);
Logger.LogInformation("Estratti {Count} record da Salesforce {EntityName}",
records.Count, selectedSalesforceSourceEntity.Name);
return records;
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nell'estrazione dati da Salesforce {EntityName}",
selectedSalesforceSourceEntity?.Name ?? "N/A");
throw;
}
}
}
+328 -17
View File
@@ -8,6 +8,7 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@inject IDataConnectionCredentialService CredentialService @inject IDataConnectionCredentialService CredentialService
@inject IOdbcDsnDiscoveryService OdbcDsnDiscoveryService @inject IOdbcDsnDiscoveryService OdbcDsnDiscoveryService
@inject IOleDbProviderDiscoveryService OleDbProviderDiscoveryService
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -242,6 +243,7 @@ else
<option value="@CredentialManager.Models.DatabaseType.DB2">DB2</option> <option value="@CredentialManager.Models.DatabaseType.DB2">DB2</option>
<option value="@CredentialManager.Models.DatabaseType.SapHana">SAP HANA</option>*@ <option value="@CredentialManager.Models.DatabaseType.SapHana">SAP HANA</option>*@
<option value="@CredentialManager.Models.DatabaseType.Odbc">ODBC</option> <option value="@CredentialManager.Models.DatabaseType.Odbc">ODBC</option>
<option value="@CredentialManager.Models.DatabaseType.OleDb">OLE DB</option>
</InputSelect> </InputSelect>
</div> </div>
</div> </div>
@@ -465,6 +467,148 @@ else
</div> </div>
</div> </div>
} }
else if (currentDatabaseCredential.DatabaseType == CredentialManager.Models.DatabaseType.OleDb)
{
<!-- Configurazione OLE DB -->
<div class="card mb-3">
<div class="card-header bg-warning text-dark">
<h6 class="mb-0"><i class="oi oi-link-intact"></i> Configurazione OLE DB</h6>
</div>
<div class="card-body">
<div class="alert alert-danger py-2">
<i class="oi oi-warning"></i> <strong>Attenzione — Compatibilità 32-bit:</strong>
Driver come <strong>VFPOLEDB.1</strong> (Visual FoxPro) sono <strong>esclusivamente 32-bit</strong>.
Pubblica l'applicazione con <code>dotnet publish --runtime win-x86</code>.
Vedi <strong>PUBLISH_32BIT_64BIT.md</strong> per tutti i dettagli.
</div>
<div class="mb-3">
<label class="form-label">
Provider OLE DB *
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" @onclick="RefreshOleDbProviderList">
<i class="oi oi-reload"></i> Aggiorna Lista
</button>
</label>
@if (availableOleDbProviders.Any())
{
<select class="form-select" @bind="selectedOleDbProvider">
<option value="">-- Seleziona Provider --</option>
@foreach (var prov in availableOleDbProviders)
{
<option value="@prov.ProgId">@prov.ProgId — @prov.Description</option>
}
</select>
@if (!string.IsNullOrEmpty(selectedOleDbProvider))
{
var info = availableOleDbProviders.FirstOrDefault(p => p.ProgId == selectedOleDbProvider);
if (info?.Note != null)
{
<div class="alert alert-warning mt-2 py-2 small">
<i class="oi oi-warning"></i> @info.Note
</div>
}
}
}
else
{
<div class="alert alert-info py-2 small">
<i class="oi oi-info"></i> Nessun provider OLE DB rilevato automaticamente.
Potrebbe essere necessario eseguire in modalità 32-bit. Inserisci manualmente il ProgID:
</div>
}
<InputText class="form-control mt-1" @bind-Value="selectedOleDbProvider"
placeholder="es. VFPOLEDB.1, Microsoft.ACE.OLEDB.12.0, Microsoft.Jet.OLEDB.4.0" />
<small class="form-text text-muted">
Provider comuni: <code>VFPOLEDB.1</code> (VFP), <code>Microsoft.ACE.OLEDB.12.0</code> (Access/Excel), <code>Microsoft.Jet.OLEDB.4.0</code> (Access 97-2003)
</small>
</div>
@if (IsVfpProvider())
{
<!-- Sezione specifica Visual FoxPro -->
<div class="mb-3">
<label class="form-label">Percorso Database VFP * <small class="text-muted">(.dbc o cartella .dbf)</small></label>
<InputText class="form-control font-monospace"
@bind-Value="currentDatabaseCredential.DatabaseName"
placeholder="es. C:\VFP\Database\miodb.dbc oppure C:\VFP\Tabelle\" />
<small class="form-text text-muted">
Per database container (<code>.dbc</code>): percorso completo al file.<br />
Per tabelle free (<code>.dbf</code>): percorso della cartella contenente i file.
</small>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Collating Sequence</label>
<select class="form-select" @bind="oleDbCollatingSequence">
<option value="">-- Default (machine) --</option>
<option value="machine">machine</option>
<option value="general">general</option>
<option value="spanish">spanish</option>
<option value="dutch">dutch</option>
<option value="french">french</option>
<option value="german">german</option>
<option value="italian">italian</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Record Cancellati (DELETED)</label>
<select class="form-select" @bind="oleDbDeleted">
<option value="">-- Default (ON) --</option>
<option value="ON">ON — escludi record cancellati</option>
<option value="OFF">OFF — includi record cancellati</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Username <small class="text-muted">(raramente richiesto per VFP)</small></label>
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Username" placeholder="Opzionale" />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Password <small class="text-muted">(raramente richiesta per VFP)</small></label>
<InputText type="password" class="form-control" @bind-Value="currentDatabaseCredential.Password" placeholder="Opzionale" />
</div>
</div>
</div>
}
else
{
<div class="mb-3">
<label class="form-label">Data Source <small class="text-muted">(percorso file o server)</small></label>
<InputText class="form-control" @bind-Value="currentDatabaseCredential.DatabaseName"
placeholder="es. C:\Access\miodb.accdb oppure server\database" />
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Username</label>
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Username" placeholder="Opzionale" />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Password</label>
<InputText type="password" class="form-control" @bind-Value="currentDatabaseCredential.Password" placeholder="Opzionale" />
</div>
</div>
</div>
}
<div class="mb-3">
<label class="form-label">Anteprima Connection String</label>
<textarea class="form-control font-monospace" rows="3" readonly>@GetOleDbConnectionStringPreview()</textarea>
<small class="form-text text-muted">Anteprima della connection string che verrà generata</small>
</div>
</div>
</div>
}
else else
{ {
<!-- Configurazione Standard Database --> <!-- Configurazione Standard Database -->
@@ -474,12 +618,27 @@ else
<label class="form-label">Host/Server *</label> <label class="form-label">Host/Server *</label>
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Host" <InputText class="form-control" @bind-Value="currentDatabaseCredential.Host"
placeholder="es. localhost o server.dominio.com" /> placeholder="es. localhost o server.dominio.com" />
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
{
<div class="form-text">
<strong>SQL Server locale:</strong><br/>
• Named Instance: <code>localhost\SQLEXPRESS</code> o <code>.\SQLEXPRESS</code><br/>
• LocalDB: <code>(localdb)\MSSQLLocalDB</code><br/>
• Default: <code>localhost</code> o <code>.</code> (usa porta 1433)
</div>
}
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Porta *</label> <label class="form-label">Porta *</label>
<InputNumber class="form-control" @bind-Value="currentDatabaseCredential.Port" /> <InputNumber class="form-control" @bind-Value="currentDatabaseCredential.Port" />
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
{
<div class="form-text">
<small>Ignorata per named instances e LocalDB</small>
</div>
}
</div> </div>
</div> </div>
</div> </div>
@@ -495,13 +654,26 @@ else
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username *</label> <label class="form-label">Username *</label>
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Username" /> <InputText class="form-control" @bind-Value="currentDatabaseCredential.Username"
placeholder="o scrivi 'Integrated' per Windows Auth" />
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
{
<div class="form-text">
<small>Per Windows Authentication, scrivi <strong>Integrated</strong> o lascia vuoto</small>
</div>
}
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password *</label> <label class="form-label">Password *</label>
<InputText type="password" class="form-control" @bind-Value="currentDatabaseCredential.Password" /> <InputText type="password" class="form-control" @bind-Value="currentDatabaseCredential.Password" />
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
{
<div class="form-text">
<small>Non richiesta per Windows Authentication</small>
</div>
}
</div> </div>
</div> </div>
</div> </div>
@@ -635,11 +807,30 @@ else
} <!-- Campi specifici per Salesforce --> } <!-- Campi specifici per Salesforce -->
@if (currentRestApiCredential.ServiceType == RestServiceType.Salesforce) @if (currentRestApiCredential.ServiceType == RestServiceType.Salesforce)
{ {
<div class="alert alert-info"> <div class="row mb-3">
<strong>Opzioni di Autenticazione:</strong><br/> <div class="col-md-12">
• <strong>Username/Password + Security Token:</strong> Autenticazione standard<br/> <label class="form-label fw-semibold">Tipo di Autenticazione OAuth2</label>
• <strong>Username/Password + Client ID/Secret:</strong> Autenticazione OAuth<br/> <InputSelect class="form-select" @bind-Value="currentRestApiCredential.GrantType">
• Il Security Token è richiesto solo se non si configura una Connected App (Client ID/Secret) <option value="Password">Password Flow — Username + Password + Security Token (grant_type=password)</option>
<option value="ClientCredentials">Client Credentials — Server-to-Server, nessun utente (grant_type=client_credentials)</option>
</InputSelect>
@if (currentRestApiCredential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
<div class="alert alert-warning mt-2 py-2">
<i class="fa fa-exclamation-triangle me-1"></i>
<strong>client_credentials</strong>: il <strong>Base URL</strong> deve essere il <strong>My Domain URL</strong> della tua org
(es. <code>https://myorg.my.salesforce.com</code>), <strong>non</strong> login.salesforce.com.<br/>
Richiede: Connected App con "Enable Client Credentials Flow" attivato e un Integration User assegnato.
</div>
}
else
{
<div class="alert alert-info mt-2 py-2">
<i class="fa fa-info-circle me-1"></i>
<strong>password flow</strong>: Username, Password + Security Token. Client ID/Secret facoltativi (Connected App).
</div>
}
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@@ -659,11 +850,15 @@ else
<div class="form-text">Esempio: 59.0</div> <div class="form-text">Esempio: 59.0</div>
</div> </div>
</div> </div>
</div> <div class="mb-3">
<label class="form-label">@GetFieldLabel("SecurityToken", currentRestApiCredential.ServiceType)</label>
<InputText type="password" class="form-control" @bind-Value="currentRestApiCredential.SecurityToken" />
<div class="form-text">Token di sicurezza Salesforce (richiesto solo se non si usa OAuth o Connected App)</div>
</div> </div>
@if (currentRestApiCredential.GrantType != CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
<div class="mb-3">
<label class="form-label">@GetFieldLabel("SecurityToken", currentRestApiCredential.ServiceType)</label>
<InputText type="password" class="form-control" @bind-Value="currentRestApiCredential.SecurityToken" />
<div class="form-text">Token di sicurezza Salesforce (richiesto solo se non si usa OAuth o Connected App)</div>
</div>
}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@@ -710,8 +905,9 @@ else
} }
<!-- Campi comuni per autenticazione username/password --> <!-- Campi comuni per autenticazione username/password -->
@if (currentRestApiCredential.ServiceType != RestServiceType.Generic || @if ((currentRestApiCredential.ServiceType != RestServiceType.Generic ||
(string.IsNullOrEmpty(currentRestApiCredential.ApiKey) && string.IsNullOrEmpty(currentRestApiCredential.AuthToken))) (string.IsNullOrEmpty(currentRestApiCredential.ApiKey) && string.IsNullOrEmpty(currentRestApiCredential.AuthToken))) &&
!(currentRestApiCredential.ServiceType == RestServiceType.Salesforce && currentRestApiCredential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials))
{ <div class="row"> { <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
@@ -833,6 +1029,12 @@ else
private string selectedOdbcDriver = string.Empty; private string selectedOdbcDriver = string.Empty;
private bool loadingOdbcData = false; private bool loadingOdbcData = false;
// OLE DB specific state
private List<OleDbProviderInfo> availableOleDbProviders = new();
private string selectedOleDbProvider = string.Empty;
private string oleDbCollatingSequence = string.Empty;
private string oleDbDeleted = string.Empty;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ await RefreshCredentials(); { await RefreshCredentials();
CheckForProblematicCredentials(); CheckForProblematicCredentials();
@@ -879,6 +1081,11 @@ else
{ {
await LoadOdbcData(); await LoadOdbcData();
} }
// Se è OLE DB, carica i provider
if (currentDatabaseCredential.DatabaseType == DatabaseType.OleDb)
{
LoadOleDbData();
}
} }
private async Task EditDatabaseCredential(DatabaseCredential credential) private async Task EditDatabaseCredential(DatabaseCredential credential)
@@ -911,6 +1118,19 @@ else
selectedOdbcDriver = currentDatabaseCredential.AdditionalParameters["Driver"]; selectedOdbcDriver = currentDatabaseCredential.AdditionalParameters["Driver"];
} }
} }
// Se è OLE DB, carica i provider e ripristina il provider selezionato
if (currentDatabaseCredential.DatabaseType == DatabaseType.OleDb)
{
LoadOleDbData();
if (currentDatabaseCredential.AdditionalParameters?.ContainsKey("Provider") == true)
{
selectedOleDbProvider = currentDatabaseCredential.AdditionalParameters["Provider"];
}
if (currentDatabaseCredential.AdditionalParameters?.ContainsKey("Collating Sequence") == true)
oleDbCollatingSequence = currentDatabaseCredential.AdditionalParameters["Collating Sequence"];
if (currentDatabaseCredential.AdditionalParameters?.ContainsKey("DELETED") == true)
oleDbDeleted = currentDatabaseCredential.AdditionalParameters["DELETED"];
}
showDatabaseModal = true; showDatabaseModal = true;
} }
@@ -919,6 +1139,22 @@ else
{ {
try try
{ {
// Sincronizza i parametri OLE DB negli AdditionalParameters prima del salvataggio
if (currentDatabaseCredential.DatabaseType == DatabaseType.OleDb)
{
currentDatabaseCredential.AdditionalParameters ??= new Dictionary<string, string>();
if (!string.IsNullOrEmpty(selectedOleDbProvider))
currentDatabaseCredential.AdditionalParameters["Provider"] = selectedOleDbProvider;
if (!string.IsNullOrEmpty(oleDbCollatingSequence))
currentDatabaseCredential.AdditionalParameters["Collating Sequence"] = oleDbCollatingSequence;
else
currentDatabaseCredential.AdditionalParameters.Remove("Collating Sequence");
if (!string.IsNullOrEmpty(oleDbDeleted))
currentDatabaseCredential.AdditionalParameters["DELETED"] = oleDbDeleted;
else
currentDatabaseCredential.AdditionalParameters.Remove("DELETED");
}
await CredentialService.SaveDatabaseCredentialAsync(currentDatabaseCredential); await CredentialService.SaveDatabaseCredentialAsync(currentDatabaseCredential);
await JSRuntime.InvokeVoidAsync("alert", "Credenziale database salvata con successo!"); await JSRuntime.InvokeVoidAsync("alert", "Credenziale database salvata con successo!");
CloseDatabaseModal(); CloseDatabaseModal();
@@ -994,13 +1230,28 @@ else
else else
{ {
// Altri database: validazione standard (Host, Username, Password) // Altri database: validazione standard (Host, Username, Password)
if (string.IsNullOrEmpty(currentDatabaseCredential.Host) || // Per SQL Server, permetti Windows Authentication (username vuoto o "Integrated")
string.IsNullOrEmpty(currentDatabaseCredential.Username) || bool isSqlServerWithWindowsAuth = currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer &&
string.IsNullOrEmpty(currentDatabaseCredential.Password)) (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", "Compila tutti i campi obbligatori (Host, Username, Password)."); await JSRuntime.InvokeVoidAsync("alert", "Il campo Host è obbligatorio.");
return; 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 (success, message) = await CredentialService.TestDatabaseConnectionAsync(currentDatabaseCredential);
@@ -1222,6 +1473,64 @@ else
StateHasChanged(); StateHasChanged();
} }
// OLE DB Methods
private void LoadOleDbData()
{
try
{
availableOleDbProviders = OleDbProviderDiscoveryService.GetInstalledProviders();
}
catch
{
availableOleDbProviders = new List<OleDbProviderInfo>();
}
}
private void RefreshOleDbProviderList()
{
LoadOleDbData();
StateHasChanged();
}
private bool IsVfpProvider()
{
if (string.IsNullOrEmpty(selectedOleDbProvider))
return false;
return selectedOleDbProvider.StartsWith("VFPOLEDB", StringComparison.OrdinalIgnoreCase);
}
private string GetOleDbConnectionStringPreview()
{
if (currentDatabaseCredential.DatabaseType != DatabaseType.OleDb)
return string.Empty;
try
{
// Copia i parametri OLE DB nei AdditionalParameters temporaneamente
currentDatabaseCredential.AdditionalParameters ??= new Dictionary<string, string>();
if (!string.IsNullOrEmpty(selectedOleDbProvider))
currentDatabaseCredential.AdditionalParameters["Provider"] = selectedOleDbProvider;
if (!string.IsNullOrEmpty(oleDbCollatingSequence))
currentDatabaseCredential.AdditionalParameters["Collating Sequence"] = oleDbCollatingSequence;
else
currentDatabaseCredential.AdditionalParameters.Remove("Collating Sequence");
if (!string.IsNullOrEmpty(oleDbDeleted))
currentDatabaseCredential.AdditionalParameters["DELETED"] = oleDbDeleted;
else
currentDatabaseCredential.AdditionalParameters.Remove("DELETED");
return ConnectionStringBuilder.BuildConnectionString(currentDatabaseCredential);
}
catch (Exception ex)
{
return $"Errore nella generazione: {ex.Message}";
}
}
#endregion #endregion
#endregion #endregion
@@ -1269,6 +1578,7 @@ else
ApiVersion = credential.ApiVersion, ApiVersion = credential.ApiVersion,
IsSandbox = credential.IsSandbox, IsSandbox = credential.IsSandbox,
UseSoapApi = credential.UseSoapApi, UseSoapApi = credential.UseSoapApi,
GrantType = credential.GrantType,
RefreshToken = credential.RefreshToken, RefreshToken = credential.RefreshToken,
AccessToken = credential.AccessToken, AccessToken = credential.AccessToken,
TokenExpiry = credential.TokenExpiry TokenExpiry = credential.TokenExpiry
@@ -1490,7 +1800,8 @@ else
ClientSecret = currentRestApiCredential.ClientSecret, ClientSecret = currentRestApiCredential.ClientSecret,
ApiVersion = currentRestApiCredential.ApiVersion, ApiVersion = currentRestApiCredential.ApiVersion,
IsSandbox = currentRestApiCredential.IsSandbox, IsSandbox = currentRestApiCredential.IsSandbox,
UseSoapApi = currentRestApiCredential.UseSoapApi UseSoapApi = currentRestApiCredential.UseSoapApi,
GrantType = currentRestApiCredential.GrantType
}; // Salviamo temporaneamente la credenziale per il test }; // Salviamo temporaneamente la credenziale per il test
await CredentialService.SaveRestApiCredentialAsync(tempCredential); await CredentialService.SaveRestApiCredentialAsync(tempCredential);
+593 -91
View File
@@ -50,6 +50,7 @@
<option value="">-- Seleziona Tipo --</option> <option value="">-- Seleziona Tipo --</option>
<option value="database">Database</option> <option value="database">Database</option>
<option value="file">File (Excel/CSV)</option> <option value="file">File (Excel/CSV)</option>
<option value="salesforce">Salesforce (REST API)</option>
</select> </select>
</div> </div>
@@ -709,17 +710,133 @@
</div> </div>
</div> } </div> }
} }
<!-- Sezione Salesforce come Fonte -->
@if (selectedSourceType == "salesforce")
{
<!-- Selezione Credenziali Salesforce -->
<div class="mb-3">
<label class="form-label">Credenziali Salesforce:</label>
<select class="form-select" @onchange="OnSalesforceSourceCredentialChanged" value="@selectedSalesforceSourceCredential">
<option value="">-- Seleziona Salesforce --</option>
@foreach (var cred in salesforceSourceCredentials)
{
<option value="@cred.Name">@cred.Name (@cred.BaseUrl)</option>
}
</select>
</div>
@if (!string.IsNullOrEmpty(selectedSalesforceSourceCredential))
{
<div class="mb-3">
<button class="btn btn-success btn-sm" @onclick="ConnectToSalesforceSource" disabled="@isConnectingSalesforceSource">
@if (isConnectingSalesforceSource)
{
<span class="spinner-border spinner-border-sm me-2"></span>
}
<i class="fas fa-plug"></i> Connetti e Scopri SObject
</button>
@if (isSalesforceSourceConnected)
{
<span class="badge bg-success ms-2">Connesso</span>
}
</div>
}
@if (!string.IsNullOrEmpty(salesforceSourceErrorMessage))
{
<div class="alert alert-danger" role="alert">
@salesforceSourceErrorMessage
</div>
}
<!-- Lista SObject Salesforce -->
@if (salesforceSourceEntities.Any())
{
<div class="mb-3">
<h6>SObject Salesforce (@salesforceSourceEntities.Count disponibili):</h6>
<div class="mb-2">
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" placeholder="Cerca SObject..."
@bind="salesforceSourceSearchTerm" @oninput="FilterSalesforceSourceEntities" />
@if (!string.IsNullOrEmpty(salesforceSourceSearchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSalesforceSourceSearch">
<i class="fas fa-times"></i>
</button>
}
</div>
</div>
<div class="list-group" style="max-height: 300px; overflow-y: auto;">
@foreach (var entity in GetFilteredSalesforceSourceEntities())
{
<a class="list-group-item list-group-item-action @(selectedSalesforceSourceEntity?.Name == entity.Name ? "active" : "")"
@onclick="@(async () => await SelectSalesforceSourceEntity(entity))">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-cloud"></i> @entity.Name
@if (!string.IsNullOrEmpty(entity.Label))
{
<small class="text-muted d-block">@entity.Label</small>
}
</div>
@if (selectedSalesforceSourceEntity?.Name == entity.Name)
{
<span class="badge bg-primary">Selezionato</span>
}
</div>
</a>
}
</div>
@if (!GetFilteredSalesforceSourceEntities().Any())
{
<div class="alert alert-info mt-2">
<i class="fas fa-info-circle"></i> Nessun SObject trovato con il termine "@salesforceSourceSearchTerm"
</div>
}
</div>
}
}
</div> </div>
</div> </div>
</div> </div>
<!-- Lato Destro - REST API --> <!-- Lato Destro - Destinazione (REST API o Database) -->
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header bg-info text-white"> <div class="card-header bg-info text-white">
<h5><i class="fas fa-cloud"></i> REST API Destination</h5> <div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
@if (selectedDestinationType == "database")
{
<i class="fas fa-database"></i>
<span> Database Destination</span>
}
else
{
<i class="fas fa-cloud"></i>
<span> REST API Destination</span>
}
</h5>
<!-- Toggle Tipo Destinazione -->
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn @(selectedDestinationType == "rest" ? "btn-light" : "btn-outline-light")"
@onclick="@(() => OnDestinationTypeChanged(new ChangeEventArgs { Value = "rest" }))">
<i class="fas fa-cloud"></i> REST API
</button>
<button type="button" class="btn @(selectedDestinationType == "database" ? "btn-light" : "btn-outline-light")"
@onclick="@(() => OnDestinationTypeChanged(new ChangeEventArgs { Value = "database" }))">
<i class="fas fa-database"></i> Database
</button>
</div>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- ===== DESTINAZIONE REST API ===== -->
@if (selectedDestinationType == "rest")
{
<!-- Selezione Credenziali REST --> <!-- Selezione Credenziali REST -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Credenziali REST API:</label> <label class="form-label">Credenziali REST API:</label>
@@ -807,19 +924,111 @@
</div> </div>
} </div> } </div>
} }
} @* fine @if (selectedDestinationType == "rest") *@
<!-- ===== DESTINAZIONE DATABASE ===== -->
@if (selectedDestinationType == "database")
{
<!-- Selezione Credenziali Database Destinazione -->
<div class="mb-3">
<label class="form-label">Credenziali Database:</label>
<select class="form-select" @onchange="OnDestinationDatabaseCredentialChanged" value="@selectedDestinationDatabaseCredential">
<option value="">-- Seleziona Database --</option>
@foreach (var cred in databaseCredentials)
{
<option value="@cred.Name">@cred.Name (@cred.DatabaseType - @cred.Host)</option>
}
</select>
</div>
@if (!string.IsNullOrEmpty(selectedDestinationDatabaseCredential))
{
<div class="mb-3">
<button class="btn btn-success btn-sm" @onclick="ConnectToDestinationDatabase" disabled="@isConnectingDestinationDatabase">
@if (isConnectingDestinationDatabase)
{
<span class="spinner-border spinner-border-sm me-2"></span>
}
<i class="fas fa-plug"></i> Connetti e Scopri Tabelle
</button>
@if (isDestinationDatabaseConnected)
{
<span class="badge bg-success ms-2">Connesso</span>
}
</div>
}
@if (!string.IsNullOrEmpty(destinationDatabaseErrorMessage))
{
<div class="alert alert-danger" role="alert">
@destinationDatabaseErrorMessage
</div>
}
<!-- Lista Tabelle Database Destinazione -->
@if (destAvailableTableNames.Any())
{
<div class="mb-3">
<h6>Tabelle Database (@destAvailableTableNames.Count disponibili):</h6>
<div class="mb-2">
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" placeholder="Cerca tabelle..."
@bind="destDatabaseSearchTerm" @oninput="FilterDestinationTables" />
@if (!string.IsNullOrEmpty(destDatabaseSearchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearDestinationTableSearch">
<i class="fas fa-times"></i>
</button>
}
</div>
</div>
<div class="list-group" style="max-height: 300px; overflow-y: auto;">
@foreach (var table in GetFilteredDestinationTables())
{
<a class="list-group-item list-group-item-action @(selectedDestinationTable == table ? "active" : "")"
@onclick="@(async () => await SelectDestinationTable(table))">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-table"></i> @table
@if (destDatabaseTables.ContainsKey(table))
{
<small class="text-muted d-block">@destDatabaseTables[table].Count() colonne</small>
}
</div>
@if (selectedDestinationTable == table)
{
<span class="badge bg-primary">Selezionata</span>
}
</div>
</a>
}
</div>
@if (!GetFilteredDestinationTables().Any())
{
<div class="alert alert-info mt-2">
<i class="fas fa-info-circle"></i> Nessuna tabella trovata con "@destDatabaseSearchTerm"
</div>
}
</div>
}
}
</div> </div>
</div> </div>
</div> </div>
</div> <!-- Sezione Mapping (quando la fonte è selezionata e REST è connesso) --> </div> <!-- Sezione Mapping -->
@{ @{
// Per ODBC: non richiede isDatabaseConnected, basta query validata // Per ODBC: non richiede isDatabaseConnected, basta query validata
// Per altri database: richiede connessione + (query validata OR tabella selezionata) // Per altri database: richiede connessione + (query validata OR tabella selezionata)
var isSourceReady = (selectedSourceType == "database" && var isSourceReady = (selectedSourceType == "database" &&
((IsOdbcConnection() && useCustomQuery && isQueryValid) || ((IsOdbcConnection() && useCustomQuery && isQueryValid) ||
(!IsOdbcConnection() && isDatabaseConnected && ((useCustomQuery && isQueryValid) || (!useCustomQuery && !string.IsNullOrEmpty(selectedTable)))))) || (!IsOdbcConnection() && isDatabaseConnected && ((useCustomQuery && isQueryValid) || (!useCustomQuery && !string.IsNullOrEmpty(selectedTable)))))) ||
(selectedSourceType == "file" && !string.IsNullOrEmpty(selectedSheet)); (selectedSourceType == "file" && !string.IsNullOrEmpty(selectedSheet)) ||
(selectedSourceType == "salesforce" && isSalesforceSourceConnected && selectedSalesforceSourceEntity != null);
var isDestinationReady = (selectedDestinationType == "rest" && isRestConnected && selectedRestEntity != null) ||
(selectedDestinationType == "database" && IsDestinationDatabaseReady);
} }
@if (isSourceReady && isRestConnected && selectedRestEntity != null) @if (isSourceReady && isDestinationReady)
{ {
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
@@ -828,11 +1037,17 @@
<h5><i class="fas fa-exchange-alt"></i> Mapping Campi</h5> <h5><i class="fas fa-exchange-alt"></i> Mapping Campi</h5>
</div> <div class="card-body"> </div> <div class="card-body">
@{ @{
var sourceDisplayName = selectedSourceType == "database" ? selectedTable : selectedSheet; var sourceDisplayName = selectedSourceType == "database" ? selectedTable :
var sourceTypeName = selectedSourceType == "database" ? "Tabella" : "Foglio"; selectedSourceType == "salesforce" ? selectedSalesforceSourceEntity?.Name ?? "" :
selectedSheet;
var sourceTypeName = selectedSourceType == "database" ? "Tabella" :
selectedSourceType == "salesforce" ? "SObject" :
"Foglio";
var destDisplayName = selectedDestinationType == "database" ? selectedDestinationTable : selectedRestEntity?.Name ?? "";
var destTypeName = selectedDestinationType == "database" ? "Tabella DB" : "Entità REST";
} }
<h6>Mapping tra @sourceTypeName @sourceDisplayName e @selectedRestEntity.Name</h6> <h6>Mapping tra @sourceTypeName @sourceDisplayName e @destDisplayName</h6>
<p class="text-muted">Configura il mapping tra i campi della fonte dati e le proprietà dell'entità REST</p> <p class="text-muted">Configura il mapping tra i campi della fonte dati e le proprietà della destinazione</p>
<div class="row"> <div class="row">
<!-- Colonna Sinistra: Campi Fonte --> <!-- Colonna Sinistra: Campi Fonte -->
<div class="col-5"> <div class="col-5">
@@ -914,54 +1129,19 @@
</a> </a>
} }
} }
</div> else if (selectedSourceType == "salesforce" && salesforceSourceEntityDetails != null)
</div>
<!-- Colonna Centrale: Controlli Mapping -->
<div class="col-2 text-center">
<div class="d-flex flex-column justify-content-center h-100">
<button class="btn btn-success mb-2" @onclick="CreateMapping"
disabled="@(string.IsNullOrEmpty(selectedDbColumn) || string.IsNullOrEmpty(selectedRestProperty))">
<i class="fas fa-arrow-right"></i>
<small class="d-block">Map</small>
</button>
<button class="btn btn-danger mb-2" @onclick="RemoveMapping"
disabled="@(string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn))">
<i class="fas fa-times"></i>
<small class="d-block">Remove</small>
</button>
<button class="btn btn-warning mb-2" @onclick="AutoMapFields">
<i class="fas fa-magic"></i>
<small class="d-block">Auto</small>
</button>
<button class="btn btn-secondary" @onclick="ClearAllMappings">
<i class="fas fa-trash"></i>
<small class="d-block">Clear</small>
</button>
</div>
</div>
<!-- Colonna Destra: Proprietà REST -->
<div class="col-5">
<h6>Proprietà REST (@selectedRestEntity.Name)</h6>
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
@if (restEntityDetails != null)
{ {
@foreach (var property in restEntityDetails.Properties) @foreach (var field in salesforceSourceEntityDetails.Properties)
{ {
<a class="list-group-item list-group-item-action @(selectedRestProperty == property.Name ? "active" : "")" <a class="list-group-item list-group-item-action @(selectedDbColumn == field.Name ? "active" : "")"
@onclick="@(() => SelectRestProperty(property.Name))"> @onclick="@(() => SelectDbColumn(field.Name))">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<strong>@property.Name</strong> <strong>@field.Name</strong>
<small class="text-muted d-block">@property.Type</small> <small class="text-muted d-block">@field.Type</small>
</div> </div>
<div> <div>
@if (property.IsRequired) @if (fieldMappings.ContainsKey(field.Name))
{
<span class="badge bg-danger">Required</span>
}
@if (fieldMappings.ContainsValue(property.Name))
{ {
<span class="badge bg-success">Mapped</span> <span class="badge bg-success">Mapped</span>
} }
@@ -972,13 +1152,274 @@
} }
</div> </div>
</div> </div>
<!-- Colonna Centrale: Controlli Mapping -->
<div class="col-2 text-center">
<div class="d-flex flex-column justify-content-center h-100">
<!-- Toggle tra Mapping e Default Value -->
<div class="btn-group mb-3" role="group">
<button type="button"
class="btn btn-sm @(isAddingDefaultValue ? "btn-outline-primary" : "btn-primary")"
@onclick="@(() => isAddingDefaultValue = false)">
<i class="fas fa-arrows-alt-h"></i>
<small class="d-block">Mapping</small>
</button>
<button type="button"
class="btn btn-sm @(isAddingDefaultValue ? "btn-warning" : "btn-outline-warning")"
@onclick="@(() => isAddingDefaultValue = true)">
<i class="fas fa-file-alt"></i>
<small class="d-block">Default</small>
</button>
</div>
<!-- Controlli per Mapping Normale -->
@if (!isAddingDefaultValue)
{
<button class="btn btn-success mb-2" @onclick="CreateMapping"
disabled="@(string.IsNullOrEmpty(selectedDbColumn) || string.IsNullOrEmpty(selectedRestProperty))">
<i class="fas fa-arrow-right"></i>
<small class="d-block">Map</small>
</button>
<button class="btn btn-danger mb-2" @onclick="RemoveMapping"
disabled="@(string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn))">
<i class="fas fa-times"></i>
<small class="d-block">Remove</small>
</button>
<button class="btn btn-warning mb-2" @onclick="AutoMapFields">
<i class="fas fa-magic"></i>
<small class="d-block">Auto</small>
</button>
}
else
{
<!-- Controlli per Default Value -->
<div class="mb-2">
<small class="text-muted d-block mb-1">Tipo Valore:</small>
<select class="form-select form-select-sm mb-2" @bind="defaultValueType">
<option value="string">String</option>
<option value="int">Integer</option>
<option value="decimal">Decimal</option>
<option value="boolean">Boolean</option>
<option value="datetime">DateTime</option>
</select>
<input type="text" class="form-control form-control-sm mb-2"
placeholder="Valore default..."
@bind="defaultValueInput" />
<small class="text-muted d-block mb-2">
@if (defaultValueType == "datetime")
{
<span>Es: @DateTime.Now.ToString("yyyy-MM-dd")</span>
}
else if (defaultValueType == "boolean")
{
<span>Es: true o false</span>
}
else if (defaultValueType == "decimal")
{
<span>Es: 100.50</span>
}
</small>
</div>
<button class="btn btn-warning mb-2" @onclick="CreateDefaultValue"
disabled="@(string.IsNullOrEmpty(selectedRestProperty) || string.IsNullOrEmpty(defaultValueInput))">
<i class="fas fa-check"></i>
<small class="d-block">Set Default</small>
</button>
}
<button class="btn btn-secondary" @onclick="ClearAllMappings">
<i class="fas fa-trash"></i>
<small class="d-block">Clear All</small>
</button>
</div>
</div>
<!-- Colonna Destra: Proprietà Destinazione -->
<div class="col-5">
@if (selectedDestinationType == "database" && !string.IsNullOrEmpty(selectedDestinationTable) && destDatabaseTables.ContainsKey(selectedDestinationTable))
{
<h6>Colonne Tabella (@selectedDestinationTable)</h6>
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
@foreach (var col in destDatabaseTables[selectedDestinationTable])
{
<a class="list-group-item list-group-item-action @(selectedRestProperty == col.Name ? "active" : "")"
@onclick="@(() => SelectRestProperty(col.Name))">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>@col.Name</strong>
<small class="text-muted d-block">@col.DataType</small>
</div>
<div>
@if (col.IsPrimaryKey)
{
<span class="badge bg-warning text-dark">PK</span>
}
@if (fieldMappings.ContainsValue(col.Name))
{
<span class="badge bg-success">Mapped</span>
}
</div>
</div>
</a>
}
</div>
}
else
{
<h6>Proprietà REST (@(selectedRestEntity?.Name ?? ""))</h6>
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
@if (restEntityDetails != null)
{
@foreach (var property in restEntityDetails.Properties)
{
<a class="list-group-item list-group-item-action @(selectedRestProperty == property.Name ? "active" : "")"
@onclick="@(() => SelectRestProperty(property.Name))">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>@property.Name</strong>
<small class="text-muted d-block">@property.Type</small>
</div>
<div>
@if (property.IsRequired)
{
<span class="badge bg-danger">Required</span>
}
@if (fieldMappings.ContainsValue(property.Name))
{
<span class="badge bg-success">Mapped</span>
}
@if (defaultValues.ContainsKey(property.Name))
{
<span class="badge bg-warning text-dark">Default</span>
}
</div>
</div>
</a>
}
}
</div>
}
</div>
</div> </div>
<!-- Sezione Mappature Correnti --> @if (fieldMappings.Any()) <!-- Sezione External ID Relationships (Salesforce) -->
@if (selectedRestEntity != null && currentRestDiscovery != null && IsSalesforceClient())
{
<div class="mt-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-link"></i> External ID Relationships (Salesforce)
</h6>
</div>
<div class="card-body">
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<strong>Relating Records by External ID</strong><br>
<small>
Crea relazioni tra oggetti usando ID esterni invece degli ID interni di Salesforce.<br>
Esempio: Collega Opportunity ad Account usando <code>Account.CardCode__c = "C60000"</code>
</small>
</div>
<!-- Form per aggiungere nuova relazione -->
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">Oggetto Correlato:</label>
<select class="form-select" @bind="selectedRelationshipObject" @bind:after="OnRelationshipObjectSelected">
<option value="">-- Seleziona Oggetto --</option>
@foreach (var entity in availableRelationshipObjects.OrderBy(e => e.Name))
{
<option value="@entity.Name">@entity.Name</option>
}
</select>
<small class="text-muted">Es: Account, Contact</small>
</div>
<div class="col-md-3">
<label class="form-label">External ID Field:</label>
<select class="form-select" @bind="selectedExternalIdField" disabled="@string.IsNullOrEmpty(selectedRelationshipObject)">
<option value="">-- Seleziona Campo --</option>
@foreach (var field in GetExternalIdFieldsForSelectedObject())
{
<option value="@field">@field</option>
}
</select>
<small class="text-muted">Es: Country__c, CardCode__c</small>
</div>
<div class="col-md-3">
<label class="form-label">Campo Sorgente:</label>
<select class="form-select" @bind="selectedRelationshipSourceField">
<option value="">-- Seleziona Campo --</option>
@foreach (var field in GetSourceFieldsForRelationship())
{
<option value="@field">@field</option>
}
</select>
<small class="text-muted">Valore da usare per la relazione</small>
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-primary w-100" @onclick="AddExternalIdRelationship"
disabled="@(string.IsNullOrEmpty(selectedRelationshipObject) || string.IsNullOrEmpty(selectedExternalIdField) || string.IsNullOrEmpty(selectedRelationshipSourceField))">
<i class="fas fa-plus"></i> Aggiungi Relazione
</button>
</div>
</div>
<!-- Tabella relazioni configurate -->
@if (externalIdRelationships.Any())
{
<div class="mt-3">
<h6>Relazioni Configurate (@externalIdRelationships.Count)</h6>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Oggetto Correlato</th>
<th>External ID Field</th>
<th>Campo Sorgente</th>
<th>Formato JSON Output</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
@foreach (var rel in externalIdRelationships)
{
<tr>
<td><strong>@rel.RelatedObjectName</strong></td>
<td><code>@rel.ExternalIdField</code></td>
<td><span class="badge bg-info">@rel.SourceField</span></td>
<td><small class="text-muted">@($"\"{rel.RelationshipName}\": {{ \"{rel.ExternalIdField}\": \"value\" }}")</small></td>
<td>
<button class="btn btn-sm btn-danger" @onclick="@(() => RemoveExternalIdRelationship(rel))">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
else
{
<div class="alert alert-secondary">
<i class="fas fa-info-circle"></i> Nessuna relazione External ID configurata. Aggiungine una se necessario.
</div>
}
</div>
</div>
</div>
}
<!-- Sezione Mappature Correnti --> @if (fieldMappings.Any() || defaultValues.Any())
{ {
<div class="mt-4"> <div class="mt-4">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h6>Mappature Correnti (@fieldMappings.Count)</h6> <h6>Configurazione Mapping (@(fieldMappings.Count + defaultValues.Count) totali)</h6>
@if (keyFields.Any()) @if (keyFields.Any())
{ {
<small class="text-info"> <small class="text-info">
@@ -986,44 +1427,101 @@
</small> </small>
} }
</div> </div>
<div class="table-responsive">
<table class="table table-sm table-striped"> <!-- Tabella Mapping Campi -->
<thead> @if (fieldMappings.Any())
<tr> {
<th>Campo Database</th> <div class="card mb-3">
<th>Tipo DB</th> <div class="card-header bg-light">
<th>→</th> <i class="fas fa-arrows-alt-h"></i> <strong>Field Mappings</strong> (@fieldMappings.Count)
<th>Proprietà REST</th> </div>
<th>Tipo REST</th> <div class="card-body p-0">
<th>Azioni</th> <div class="table-responsive">
</tr> <table class="table table-sm table-striped mb-0">
</thead> <thead>
<tbody> <tr>
@foreach (var mapping in fieldMappings) <th>Campo Sorgente</th>
{ <th>Tipo Sorgente</th>
DbColumnInfo? dbColumn = null; <th>→</th>
if (selectedSourceType == "database" && !string.IsNullOrEmpty(selectedTable)) <th>Campo Destinazione</th>
{ <th>Tipo Destinazione</th>
dbColumn = databaseTables.ContainsKey(selectedTable) ? <th>Azioni</th>
databaseTables[selectedTable].FirstOrDefault(c => c.Name == mapping.Key) : null; </tr>
} </thead>
var restProperty = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == mapping.Value); <tbody>
<tr> @foreach (var mapping in fieldMappings)
<td><strong>@mapping.Key</strong></td> {
<td><small class="text-muted">@(dbColumn?.DataType ?? (selectedSourceType == "file" ? "Text" : "Unknown"))</small></td> DbColumnInfo? dbColumn = null;
<td><i class="fas fa-arrow-right text-success"></i></td> if (selectedSourceType == "database" && !string.IsNullOrEmpty(selectedTable))
<td><strong>@mapping.Value</strong></td> {
<td><small class="text-muted">@(restProperty?.Type ?? "Unknown")</small></td> dbColumn = databaseTables.ContainsKey(selectedTable) ?
<td> databaseTables[selectedTable].FirstOrDefault(c => c.Name == mapping.Key) : null;
<button class="btn btn-sm btn-danger" @onclick="@(() => RemoveSpecificMapping(mapping.Key))"> }
<i class="fas fa-trash"></i> var restProperty = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == mapping.Value);
</button> <tr>
</td> <td><strong>@mapping.Key</strong></td>
</tr> <td><small class="text-muted">@(dbColumn?.DataType ?? (selectedSourceType == "file" ? "Text" : "Unknown"))</small></td>
} <td><i class="fas fa-arrow-right text-success"></i></td>
</tbody> <td><strong>@mapping.Value</strong></td>
</table> <td><small class="text-muted">@(restProperty?.Type ?? "Unknown")</small></td>
</div> <td>
<button class="btn btn-sm btn-danger" @onclick="@(() => RemoveSpecificMapping(mapping.Key))">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<!-- Tabella Default Values -->
@if (defaultValues.Any())
{
<div class="card mb-3">
<div class="card-header bg-warning text-dark">
<i class="fas fa-file-alt"></i> <strong>Default Values</strong> (@defaultValues.Count)
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Campo Destinazione</th>
<th>Valore Default</th>
<th>Tipo Valore</th>
<th>Tipo Campo REST</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
@foreach (var defaultValue in defaultValues)
{
var restProperty = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == defaultValue.Key);
var (value, valueType) = defaultValue.Value;
<tr>
<td><strong>@defaultValue.Key</strong></td>
<td><code>@(value?.ToString() ?? "null")</code></td>
<td>
<span class="badge bg-info">@valueType</span>
</td>
<td><small class="text-muted">@(restProperty?.Type ?? "Unknown")</small></td>
<td>
<button class="btn btn-sm btn-danger" @onclick="@(() => RemoveDefaultValue(defaultValue.Key))">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div> </div>
} }
@@ -1154,6 +1652,8 @@
</div> </div>
} }
<div class="mt-3"> <div class="mt-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
@@ -1199,6 +1699,8 @@
DestinationCredentialName="@selectedRestCredential" DestinationCredentialName="@selectedRestCredential"
DestinationEndpoint="@selectedRestEntity?.Name" DestinationEndpoint="@selectedRestEntity?.Name"
FieldMappings="@GetCurrentFieldMappings()" FieldMappings="@GetCurrentFieldMappings()"
DefaultValues="@defaultValues"
ExternalIdRelationships="@externalIdRelationships"
SourceKeyField="@sourceKeyField" SourceKeyField="@sourceKeyField"
UseRecordAssociations="@useRecordAssociations" UseRecordAssociations="@useRecordAssociations"
OnProfileSaved="@OnProfileSaved" /> OnProfileSaved="@OnProfileSaved" />
+592 -68
View File
@@ -51,10 +51,25 @@ public partial class DataCoupler : ComponentBase
(int)Math.Ceiling((double)fileData[sheetName].Count / pageSize) : 0; (int)Math.Ceiling((double)fileData[sheetName].Count / pageSize) : 0;
// Mapping campi // Mapping campi
private Dictionary<string, string> fieldMappings = new(); // DbColumn -> RestProperty private Dictionary<string, string> fieldMappings = new(); // DbColumn -> RestProperty (legacy)
private List<FieldMappingEntry> fieldMappingEntries = new(); // New system: supporta sia mapping che default values
private Dictionary<string, (object? Value, string? Type)> defaultValues = new(); // DestinationField -> (DefaultValue, Type)
private HashSet<string> keyFields = new(); // REST properties marked as keys private HashSet<string> keyFields = new(); // REST properties marked as keys
private string selectedDbColumn = ""; 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<ExternalIdRelationshipDto> externalIdRelationships = new();
private string selectedRelationshipObject = "";
private string selectedExternalIdField = "";
private string selectedRelationshipSourceField = "";
private List<RestEntityInfo> availableRelationshipObjects = new(); // Oggetti disponibili per relazioni
// Gestione chiavi sorgente e associazioni // Gestione chiavi sorgente e associazioni
private string sourceKeyField = ""; // Campo che identifica univocamente il record sorgente private string sourceKeyField = ""; // Campo che identifica univocamente il record sorgente
private bool requiresManualKeySelection = false; // Flag per indicare se è richiesta selezione manuale private bool requiresManualKeySelection = false; // Flag per indicare se è richiesta selezione manuale
@@ -82,6 +97,7 @@ public partial class DataCoupler : ComponentBase
{ {
databaseCredentials = await CredentialService.GetAllDatabaseCredentialsAsync(); databaseCredentials = await CredentialService.GetAllDatabaseCredentialsAsync();
await LoadRestCredentials(); // Carica le credenziali REST dalla classe parziale await LoadRestCredentials(); // Carica le credenziali REST dalla classe parziale
await LoadSalesforceSourceCredentials(); // Carica le credenziali Salesforce per la fonte
// Carica anche i profili disponibili // Carica anche i profili disponibili
await LoadProfiles(); await LoadProfiles();
} }
@@ -338,11 +354,13 @@ public partial class DataCoupler : ComponentBase
// Applica i mapping // Applica i mapping
fieldMappings.Clear(); fieldMappings.Clear();
fieldMappingEntries.Clear();
keyFields.Clear(); keyFields.Clear();
foreach (var mapping in mappings) foreach (var mapping in mappings)
{ {
fieldMappings[mapping.SourceField] = mapping.DestinationField; fieldMappings[mapping.SourceField] = mapping.DestinationField;
fieldMappingEntries.Add(FieldMappingEntry.CreateFieldMapping(mapping.SourceField, mapping.DestinationField));
if (mapping.IsKey) if (mapping.IsKey)
{ {
keyFields.Add(mapping.DestinationField); keyFields.Add(mapping.DestinationField);
@@ -364,6 +382,42 @@ public partial class DataCoupler : ComponentBase
Logger.LogInformation("Nessun mapping campi da applicare"); 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<Dictionary<string, DefaultValueDto>>(
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 // Step 5: Applica configurazione chiave sorgente
if (!string.IsNullOrEmpty(profile.SourceKeyField)) if (!string.IsNullOrEmpty(profile.SourceKeyField))
{ {
@@ -375,6 +429,51 @@ public partial class DataCoupler : ComponentBase
Logger.LogInformation("Nessuna chiave sorgente da applicare"); 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<List<ExternalIdRelationshipDto>>(
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 // Step 6: Applica configurazione associazioni record
useRecordAssociations = profile.UseRecordAssociations; useRecordAssociations = profile.UseRecordAssociations;
Logger.LogInformation("Step 6 - Associazioni record configurate: {UseAssociations}", useRecordAssociations); Logger.LogInformation("Step 6 - Associazioni record configurate: {UseAssociations}", useRecordAssociations);
@@ -466,6 +565,8 @@ public partial class DataCoupler : ComponentBase
existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson; existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
existingProfile.IsActive = true; existingProfile.IsActive = true;
@@ -499,6 +600,8 @@ public partial class DataCoupler : ComponentBase
existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson; existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
@@ -687,7 +790,10 @@ public partial class DataCoupler : ComponentBase
ResetSourceState(); ResetSourceState();
ResetDestinationState(); ResetDestinationState();
fieldMappings.Clear(); fieldMappings.Clear();
fieldMappingEntries.Clear();
defaultValues.Clear();
keyFields.Clear(); keyFields.Clear();
externalIdRelationships.Clear(); // Reset relazioni
transferResults.Clear(); transferResults.Clear();
transferMessage = ""; transferMessage = "";
} }
@@ -704,6 +810,7 @@ public partial class DataCoupler : ComponentBase
restSearchTerm = ""; restSearchTerm = "";
currentRestDiscovery = null; currentRestDiscovery = null;
currentRestClient = null; currentRestClient = null;
ResetDestinationDatabaseState();
} }
private void OnSourceTypeChanged(ChangeEventArgs e) private void OnSourceTypeChanged(ChangeEventArgs e)
@@ -728,6 +835,9 @@ public partial class DataCoupler : ComponentBase
fileData.Clear(); fileData.Clear();
selectedSheet = ""; selectedSheet = "";
// Reset Salesforce source state
ResetSalesforceSourceState();
// Reset pagination // Reset pagination
currentPage = 1; currentPage = 1;
@@ -1293,6 +1403,17 @@ public partial class DataCoupler : ComponentBase
// Crea il nuovo mapping // Crea il nuovo mapping
fieldMappings[selectedDbColumn] = selectedRestProperty; 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); Logger.LogInformation("Creato mapping: {DbColumn} -> {RestProperty}", selectedDbColumn, selectedRestProperty);
// Deseleziona i campi // Deseleziona i campi
@@ -1300,14 +1421,108 @@ public partial class DataCoupler : ComponentBase
selectedRestProperty = ""; 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() private void RemoveMapping()
{ {
if (string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn)) if (string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn))
return; return;
fieldMappings.Remove(selectedDbColumn); 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); 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) private void RemoveSpecificMapping(string dbColumn)
{ {
if (fieldMappings.ContainsKey(dbColumn)) if (fieldMappings.ContainsKey(dbColumn))
@@ -1320,12 +1535,171 @@ public partial class DataCoupler : ComponentBase
private void ClearAllMappings() private void ClearAllMappings()
{ {
fieldMappings.Clear(); fieldMappings.Clear();
fieldMappingEntries.Clear();
defaultValues.Clear();
selectedDbColumn = ""; selectedDbColumn = "";
selectedRestProperty = ""; selectedRestProperty = "";
sourceKeyField = ""; sourceKeyField = "";
transferMessage = ""; transferMessage = "";
transferMessageType = ""; 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();
}
}
/// <summary>
/// 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
/// </summary>
/// <param name="relatedObjectName">Nome dell'oggetto correlato (es. "Account", "Custom_Company__c")</param>
/// <param name="isDestinationCustom">True se l'oggetto destinazione è custom</param>
/// <returns>Nome normalizzato della relazione (es. "Account__r", "Account", "Custom_Company__r")</returns>
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<string> GetExternalIdFieldsForSelectedObject()
{
if (string.IsNullOrEmpty(selectedRelationshipObject))
return new List<string>();
var entity = availableRelationshipObjects.FirstOrDefault(e => e.Name == selectedRelationshipObject);
if (entity == null)
return new List<string>();
// 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<string> 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<string>();
} }
private void AutoMapFields() private void AutoMapFields()
@@ -1397,25 +1771,103 @@ public partial class DataCoupler : ComponentBase
} }
private async Task StartDataTransfer() private async Task StartDataTransfer()
{ {
// Verifica se possiamo utilizzare le chiamate Composite (solo per Salesforce) // Se destinazione è database, usa il metodo dedicato
if (selectedDestinationType == "database")
{
await StartDataTransferToDatabase();
return;
}
// Verifica se possiamo utilizzare le chiamate Composite (solo per Salesforce REST dest)
if (currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient) if (currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient)
{ {
await StartDataTransferWithComposite(); await StartDataTransferWithComposite();
return; return;
} }
// Fallback al metodo originale per altri client REST // Per altri client REST, usa il metodo originale
// Se siamo con Salesforce, usa il nuovo metodo Composite
if (currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient)
{
await StartDataTransferWithComposite();
return;
}
// Per altri client, usa il metodo originale
await StartDataTransferOriginal(); await StartDataTransferOriginal();
} }
private async Task StartDataTransferToDatabase()
{
if (!fieldMappings.Any() || currentDestinationDatabaseManager == null || string.IsNullOrEmpty(selectedDestinationTable))
{
transferMessage = "Configurazione incompleta. Assicurati di aver selezionato la fonte, la tabella di destinazione e configurato almeno un mapping.";
transferMessageType = "error";
return;
}
if (string.IsNullOrEmpty(sourceKeyField))
{
transferMessage = "Seleziona un campo chiave sorgente per l'operazione di upsert.";
transferMessageType = "error";
return;
}
isTransferringData = true;
transferMessage = "";
int successCount = 0;
int errorCount = 0;
StateHasChanged();
try
{
var sourceRecords = await GetAllRecordsFromSource();
var recordsList = sourceRecords.ToList();
transferMessage = $"Elaborazione di {recordsList.Count} record...";
StateHasChanged();
foreach (var sourceRecord in recordsList)
{
try
{
// Applica mapping
var destRecord = new Dictionary<string, object?>();
foreach (var mapping in fieldMappings)
{
if (sourceRecord.TryGetValue(mapping.Key, out var value))
destRecord[mapping.Value] = value;
}
// Aggiungi default values
foreach (var dv in defaultValues)
{
if (!destRecord.ContainsKey(dv.Key))
destRecord[dv.Key] = dv.Value;
}
// Determina chiave di destinazione
string destKeyField = fieldMappings.TryGetValue(sourceKeyField, out var mapped) ? mapped : sourceKeyField;
object? keyValue = sourceRecord.TryGetValue(sourceKeyField, out var kv) ? kv : null;
var ok = await currentDestinationDatabaseManager.UpsertRecordAsync(
selectedDestinationTable, destKeyField, keyValue, destRecord);
if (ok) successCount++;
else errorCount++;
}
catch
{
errorCount++;
}
}
transferMessage = $"Trasferimento completato: {successCount} record elaborati, {errorCount} errori.";
transferMessageType = errorCount == 0 ? "success" : "warning";
}
catch (Exception ex)
{
transferMessage = $"Errore durante il trasferimento: {ex.Message}";
transferMessageType = "error";
Logger.LogError(ex, "Errore nel trasferimento verso database");
}
finally
{
isTransferringData = false;
StateHasChanged();
}
}
private async Task StartDataTransferOriginal() private async Task StartDataTransferOriginal()
{ {
if (!fieldMappings.Any() || currentRestClient == null || selectedRestEntity == null) if (!fieldMappings.Any() || currentRestClient == null || selectedRestEntity == null)
@@ -1879,6 +2331,10 @@ public partial class DataCoupler : ComponentBase
{ {
return await GetAllRecordsFromFile(); return await GetAllRecordsFromFile();
} }
else if (selectedSourceType == "salesforce")
{
return await GetAllRecordsFromSalesforceSource();
}
return new List<Dictionary<string, object>>(); return new List<Dictionary<string, object>>();
} }
@@ -1943,11 +2399,26 @@ public partial class DataCoupler : ComponentBase
{ {
var restData = new Dictionary<string, object>(); var restData = new Dictionary<string, object>();
// 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) foreach (var mapping in fieldMappings)
{ {
string dbColumn = mapping.Key; string dbColumn = mapping.Key;
string restProperty = mapping.Value; 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)) if (dbRecord.ContainsKey(dbColumn))
{ {
var value = dbRecord[dbColumn]; var value = dbRecord[dbColumn];
@@ -1962,9 +2433,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<string, object>
{
{ 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(", ", dbRecord.Keys),
string.Join(", ", restData.Keys)); string.Join(", ", restData.Keys),
defaultValues.Count(dv => restData.ContainsKey(dv.Key)));
return restData; return restData;
} }
@@ -2458,30 +2981,40 @@ public partial class DataCoupler : ComponentBase
if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)) if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
return false; return false;
// Lista di parole chiave vietate per sicurezza // Parole chiave complete: devono essere token SQL isolati (\bKEYWORD\b)
var forbiddenKeywords = new[] // Evita falsi positivi su nomi di colonne come UpdateDate, CreateDate, DeletedAt, ecc.
var forbiddenFullKeywords = new[]
{ {
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE", "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE",
"EXEC", "EXECUTE", "sp_", "xp_", "BULK", "OPENROWSET", "OPENDATASOURCE" "EXEC", "EXECUTE", "BULK", "OPENROWSET", "OPENDATASOURCE"
}; };
// Prefissi pericolosi: devono iniziare la parola (sp_anything, xp_anything)
// Non si usa \bprefix\b perché _ è un word-char e mancherebbe sp_executesql, xp_cmdshell, ecc.
var forbiddenPrefixes = new[] { "SP_", "XP_" };
var upperQuery = trimmedQuery.ToUpperInvariant(); var upperQuery = trimmedQuery.ToUpperInvariant();
// Verifica che non contenga parole chiave vietate // Verifica parole chiave complete con word boundary
foreach (var keyword in forbiddenKeywords) foreach (var keyword in forbiddenFullKeywords)
{ {
if (upperQuery.Contains(keyword)) var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(keyword)}\b";
if (System.Text.RegularExpressions.Regex.IsMatch(upperQuery, pattern))
{ {
Logger.LogWarning("Query rifiutata: contiene parola chiave vietata '{Keyword}'", keyword); Logger.LogWarning("Query rifiutata: contiene parola chiave vietata '{Keyword}'", keyword);
return false; return false;
} }
} }
// Verifica che non contenga commenti SQL potenzialmente pericolosi // Verifica prefissi stored procedure: \bSP_ cattura sp_anything, xp_anything
if (upperQuery.Contains("--") || upperQuery.Contains("/*")) foreach (var prefix in forbiddenPrefixes)
{ {
Logger.LogWarning("Query rifiutata: contiene commenti SQL non consentiti"); var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(prefix)}";
return false; if (System.Text.RegularExpressions.Regex.IsMatch(upperQuery, pattern))
{
Logger.LogWarning("Query rifiutata: contiene prefisso stored procedure vietato '{Prefix}'", prefix);
return false;
}
} }
return true; return true;
@@ -2958,10 +3491,42 @@ public partial class DataCoupler : ComponentBase
// Crea lista indicizzata per mantenere il record number // Crea lista indicizzata per mantenere il record number
var indexedRecords = records.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList(); var indexedRecords = records.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList();
Logger.LogInformation("COMPOSITE: Inizio analisi parallela di {RecordCount} record", indexedRecords.Count); Logger.LogInformation("COMPOSITE: Inizio analisi di {RecordCount} record", indexedRecords.Count);
var analysisStartTime = DateTime.UtcNow; var analysisStartTime = DateTime.UtcNow;
// Processa tutti i record in parallelo // === STEP A: Bulk Pre-Discovery (1 query SQLite + poche SOQL IN invece di 2N+N) ===
var sourceKeysForBulk = new List<string>(indexedRecords.Count);
foreach (var idx in indexedRecords)
{
var key = GenerateSourceKey(idx.Record);
if (!string.IsNullOrEmpty(key))
sourceKeysForBulk.Add(key);
}
Dictionary<string, KeyAssociation> associationsByKey = new(StringComparer.Ordinal);
if (currentUseRecordAssociations && !string.IsNullOrEmpty(currentSourceKeyField) && sourceKeysForBulk.Count > 0)
{
var commonRequest = new PreDiscoveryRequest
{
SourceKeyField = currentSourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = GetEntityIdField(),
FieldMappings = currentFieldMappings,
RestClient = currentRestClient,
EnablePreDiscovery = true,
UseParallelMethod = true,
IsScheduledTransfer = false
};
associationsByKey = await AssociationService.BatchFindOrCreateAssociationsAsync(
sourceKeysForBulk, commonRequest);
Logger.LogInformation("COMPOSITE: Bulk Pre-Discovery completata - {Found}/{Total} associazioni risolte",
associationsByKey.Count, sourceKeysForBulk.Count);
}
// === STEP B: Analisi locale parallela (no I/O) ===
var processingTasks = indexedRecords.Select(async indexedRecord => var processingTasks = indexedRecords.Select(async indexedRecord =>
{ {
try try
@@ -2980,48 +3545,7 @@ public partial class DataCoupler : ComponentBase
// Analizza le associazioni per capire se aggiornare, creare o saltare // Analizza le associazioni per capire se aggiornare, creare o saltare
if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey)) if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey))
{ {
Logger.LogDebug("COMPOSITE PARALLEL: Cerco associazione per KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'", associationsByKey.TryGetValue(sourceKey, out var existingAssociation);
sourceKey, currentEntityName, currentCredentialName);
// Usa i metodi paralleli per le operazioni di database
var existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync(
sourceKey, currentEntityName, currentCredentialName);
// FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue
if (existingAssociation == null)
{
existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync(sourceKey);
if (existingAssociation != null)
{
// Verifica compatibilità
if (existingAssociation.DestinationEntity != currentEntityName ||
existingAssociation.RestCredentialName != currentCredentialName)
{
existingAssociation = null;
}
}
}
// 🔍 PRE-DISCOVERY: Usa il servizio centralizzato
if (existingAssociation == null)
{
var preDiscoveryRequest = new PreDiscoveryRequest
{
SourceKey = sourceKey,
SourceKeyField = currentSourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = GetEntityIdField(),
FieldMappings = currentFieldMappings,
RestClient = currentRestClient,
CurrentDataHash = currentDataHash,
EnablePreDiscovery = true,
UseParallelMethod = true, // Usa metodi paralleli thread-safe
IsScheduledTransfer = false
};
existingAssociation = await AssociationService.FindOrCreateAssociationAsync(preDiscoveryRequest);
}
if (existingAssociation != null && existingAssociation.IsActive) if (existingAssociation != null && existingAssociation.IsActive)
{ {
-47
View File
@@ -1,47 +0,0 @@
@page "/fetchdata"
@using Data_Coupler.Data
@inject WeatherForecastService ForecastService
<PageTitle>Weather forecast</PageTitle>
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from a service.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await ForecastService.GetForecastAsync(DateOnly.FromDateTime(DateTime.Now));
}
}
+21 -6
View File
@@ -311,13 +311,20 @@ public partial class Scheduling : ComponentBase
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, status, message, result.RecordsProcessed); await ScheduleService.UpdateExecutionStatusAsync(scheduleId, status, message, result.RecordsProcessed);
if (result.IsSuccess) // Notifica l'utente (best-effort: la connessione browser potrebbe essere stata interrotta
// durante un'esecuzione lunga senza che questo invalidi il risultato già salvato).
try
{ {
await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi."); if (result.IsSuccess)
await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi.");
else
await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}");
} }
else catch (OperationCanceledException)
{ {
await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}"); // La connessione Blazor è stata interrotta durante l'esecuzione: il risultato è
// già stato salvato correttamente, la notifica non può essere recapitata.
Logger.LogWarning("Notifica UI non inviata per la schedulazione {ScheduleId}: connessione browser interrotta durante l'esecuzione", scheduleId);
} }
await LoadSchedules(); await LoadSchedules();
@@ -326,7 +333,7 @@ public partial class Scheduling : ComponentBase
{ {
Logger.LogError(ex, "Errore nell'esecuzione manuale schedulazione {ScheduleId}", scheduleId); Logger.LogError(ex, "Errore nell'esecuzione manuale schedulazione {ScheduleId}", scheduleId);
// Aggiorna lo storico in caso di eccezione // Aggiorna lo storico in caso di eccezione durante l'esecuzione effettiva
if (executionHistory != null) if (executionHistory != null)
{ {
executionHistory.EndTime = DateTime.Now; executionHistory.EndTime = DateTime.Now;
@@ -337,7 +344,15 @@ public partial class Scheduling : ComponentBase
} }
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "failed", $"Errore: {ex.Message}"); await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "failed", $"Errore: {ex.Message}");
await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message);
try
{
await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message);
}
catch (OperationCanceledException)
{
Logger.LogWarning("Notifica UI non inviata per la schedulazione {ScheduleId}: connessione browser non disponibile", scheduleId);
}
} }
finally finally
{ {
+9 -1
View File
@@ -235,7 +235,15 @@
{ {
<h6>Dettagli Errori</h6> <h6>Dettagli Errori</h6>
<div class="alert alert-danger"> <div class="alert alert-danger">
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre> @if (IsDevelopment)
{
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre>
}
else
{
<p class="mb-1">@GetSanitizedErrorMessage(selectedExecution.ErrorDetails)</p>
<small class="text-muted"><i class="fas fa-info-circle"></i> Per i dettagli tecnici completi consultare i log dell'applicazione.</small>
}
</div> </div>
} }
@@ -1,6 +1,7 @@
using CredentialManager.Models; using CredentialManager.Models;
using CredentialManager.Services; using CredentialManager.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -11,6 +12,29 @@ public partial class SchedulingHistory : ComponentBase
[Inject] private IProfileScheduleService ScheduleService { get; set; } = null!; [Inject] private IProfileScheduleService ScheduleService { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!; [Inject] private IJSRuntime JSRuntime { get; set; } = null!;
[Inject] private ILogger<SchedulingHistory> Logger { get; set; } = null!; [Inject] private ILogger<SchedulingHistory> Logger { get; set; } = null!;
[Inject] private IWebHostEnvironment WebHostEnvironment { get; set; } = null!;
protected bool IsDevelopment => WebHostEnvironment.IsDevelopment();
/// <summary>
/// Restituisce solo il messaggio dell'eccezione (senza stack trace) per la visualizzazione in produzione.
/// </summary>
protected static string GetSanitizedErrorMessage(string errorDetails)
{
if (string.IsNullOrEmpty(errorDetails))
return string.Empty;
// Prende solo le righe fino al primo stack frame (riga che inizia con " at")
var lines = errorDetails.Split('\n');
var messageLines = new System.Collections.Generic.List<string>();
foreach (var line in lines)
{
if (line.TrimStart().StartsWith("at ", StringComparison.Ordinal))
break;
messageLines.Add(line.TrimEnd());
}
return string.Join("\n", messageLines).Trim();
}
protected List<ScheduleExecutionHistory>? executionHistory; protected List<ScheduleExecutionHistory>? executionHistory;
protected ScheduleExecutionHistory? selectedExecution; protected ScheduleExecutionHistory? selectedExecution;
+37
View File
@@ -10,6 +10,7 @@ using CredentialManager;
using Data_Coupler.Services; using Data_Coupler.Services;
using Data_Coupler.BackgroundServices; using Data_Coupler.BackgroundServices;
using CredentialManager.Services; using CredentialManager.Services;
using MachineGuard;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -108,6 +109,7 @@ builder.Services.AddScoped<IDataConnectionFactory, DataConnectionFactory>();
// Register ODBC DSN Discovery Service // Register ODBC DSN Discovery Service
builder.Services.AddScoped<CredentialManager.Services.IOdbcDsnDiscoveryService, CredentialManager.Services.OdbcDsnDiscoveryService>(); builder.Services.AddScoped<CredentialManager.Services.IOdbcDsnDiscoveryService, CredentialManager.Services.OdbcDsnDiscoveryService>();
builder.Services.AddScoped<CredentialManager.Services.IOleDbProviderDiscoveryService, CredentialManager.Services.OleDbProviderDiscoveryService>();
// Register Association Service (Pre-Discovery) // Register Association Service (Pre-Discovery)
builder.Services.AddScoped<Data_Coupler.Services.IAssociationService, Data_Coupler.Services.AssociationService>(); builder.Services.AddScoped<Data_Coupler.Services.IAssociationService, Data_Coupler.Services.AssociationService>();
@@ -130,6 +132,9 @@ builder.Services.AddScoped<Data_Coupler.Services.IDeletionSyncService, Data_Coup
// Register Background Services (solo uno per evitare duplicazioni) // Register Background Services (solo uno per evitare duplicazioni)
builder.Services.AddHostedService<Data_Coupler.BackgroundServices.ScheduledJobService>(); builder.Services.AddHostedService<Data_Coupler.BackgroundServices.ScheduledJobService>();
// Register MachineGuard — protezione machine-binding tramite DPAPI
builder.Services.AddMachineGuard(builder.Configuration);
// Configurazione URL e timeout per servizio Windows // Configurazione URL e timeout per servizio Windows
var urls = builder.Configuration.GetValue<string>("Urls") ?? "http://*:7550"; var urls = builder.Configuration.GetValue<string>("Urls") ?? "http://*:7550";
builder.WebHost.UseUrls(urls); builder.WebHost.UseUrls(urls);
@@ -143,6 +148,38 @@ builder.WebHost.ConfigureKestrel(serverOptions =>
var app = builder.Build(); var app = builder.Build();
#region MachineGuard verifica autorizzazione macchina
// Questa verifica deve avvenire PRIMA di qualsiasi altra inizializzazione.
// Se la macchina non è autorizzata, l'applicazione viene arrestata immediatamente.
{
var machineGuard = app.Services.GetRequiredService<IMachineGuard>();
if (!machineGuard.Verify())
{
var critLogger = app.Services.GetRequiredService<ILogger<Program>>();
critLogger.LogCritical(
"MachineGuard: questa macchina NON è autorizzata a eseguire Data Coupler. " +
"Eseguire MachineGuardSetup.exe come Amministratore per configurare questa macchina. " +
"Applicazione arrestata.");
if (OperatingSystem.IsWindows())
{
try
{
using var eventLog = new System.Diagnostics.EventLog("Application");
eventLog.Source = "DataCouplerService";
eventLog.WriteEntry(
"MachineGuard: macchina non autorizzata. " +
"Eseguire MachineGuardSetup.exe come Amministratore. Applicazione arrestata.",
System.Diagnostics.EventLogEntryType.Error);
}
catch { /* Ignora errori di scrittura EventLog */ }
}
Environment.Exit(1);
}
}
#endregion
// Initialize database con timeout e retry // Initialize database con timeout e retry
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
+1 -1
View File
@@ -12,7 +12,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "http://localhost:5135", "applicationUrl": "http://localhost:7550",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
+239
View File
@@ -1,11 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using CredentialManager.Models; using CredentialManager.Models;
using CredentialManager.Services; using CredentialManager.Services;
using DataConnection.CredentialManagement.Interfaces; using DataConnection.CredentialManagement.Interfaces;
using DataConnection.REST.Implementations;
using DataConnection.REST.Interfaces; using DataConnection.REST.Interfaces;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -69,6 +71,227 @@ public class AssociationService : IAssociationService
return null; return null;
} }
/// <summary>
/// Versione bulk del find-or-create — vedi <see cref="IAssociationService.BatchFindOrCreateAssociationsAsync"/>.
/// </summary>
public async Task<Dictionary<string, KeyAssociation>> BatchFindOrCreateAssociationsAsync(
IEnumerable<string> sourceKeys,
PreDiscoveryRequest commonRequest)
{
if (commonRequest == null)
throw new ArgumentNullException(nameof(commonRequest));
var distinctKeys = sourceKeys
.Where(k => !string.IsNullOrEmpty(k))
.Distinct()
.ToList();
var result = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
if (distinctKeys.Count == 0)
return result;
// STEP 1 — Bulk lookup nel DB locale (1 query SQLite invece di N)
Dictionary<string, KeyAssociation> localMatches;
try
{
localMatches = await _credentialService.FindKeyAssociationsByValuesBulkAsync(
distinctKeys, commonRequest.DestinationEntity, commonRequest.CredentialName);
}
catch (Exception ex)
{
_logger.LogError(ex, "BULK PRE-DISCOVERY: bulk lookup locale fallito, fallback per-record");
// Fallback per-record (mantiene comportamento esistente in caso di problemi)
foreach (var key in distinctKeys)
{
var req = ClonePreDiscoveryRequest(commonRequest, key);
var found = await FindOrCreateAssociationAsync(req);
if (found != null) result[key] = found;
}
return result;
}
foreach (var kvp in localMatches)
result[kvp.Key] = kvp.Value;
var missingKeys = distinctKeys.Where(k => !result.ContainsKey(k)).ToList();
_logger.LogInformation("BULK PRE-DISCOVERY: {Local}/{Total} associazioni trovate localmente, {Missing} da cercare nella destinazione",
localMatches.Count, distinctKeys.Count, missingKeys.Count);
if (missingKeys.Count == 0 || !commonRequest.EnablePreDiscovery)
return result;
// STEP 2 — Pre-Discovery batched sulla destinazione REST
// Verifica che il campo chiave sia mappato
if (string.IsNullOrEmpty(commonRequest.SourceKeyField) ||
!commonRequest.FieldMappings.TryGetValue(commonRequest.SourceKeyField, out var mappedField))
{
_logger.LogWarning("BULK PRE-DISCOVERY: campo chiave '{SourceKeyField}' non mappato; skip discovery",
commonRequest.SourceKeyField);
return result;
}
// Solo SalesforceServiceClient supporta SOQL IN ottimizzata; per altri client si ricade al per-record.
if (commonRequest.RestClient is SalesforceServiceClient sfClient)
{
var discovered = await PerformBulkPreDiscoverySalesforceAsync(
sfClient, missingKeys, mappedField, commonRequest);
foreach (var kvp in discovered)
result[kvp.Key] = kvp.Value;
}
else
{
_logger.LogDebug("BULK PRE-DISCOVERY: client REST non Salesforce, fallback per-record per {Count} chiavi", missingKeys.Count);
foreach (var key in missingKeys)
{
var req = ClonePreDiscoveryRequest(commonRequest, key);
var found = await PerformPreDiscoveryAsync(req);
if (found != null) result[key] = found;
}
}
return result;
}
/// <summary>
/// Pre-Discovery batched specifica per Salesforce: usa SOQL <c>WHERE field IN (...)</c>
/// per recuperare in pochissime chiamate API tutti i record che matchano una qualsiasi delle chiavi mancanti.
/// </summary>
private async Task<Dictionary<string, KeyAssociation>> PerformBulkPreDiscoverySalesforceAsync(
SalesforceServiceClient sfClient,
List<string> missingKeys,
string mappedDestinationField,
PreDiscoveryRequest commonRequest)
{
var output = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
// Chunk per stare sotto il limite SOQL/URL (~16 KB GET): ~200 valori per query
const int chunkSize = 200;
var queries = new List<string>();
for (int i = 0; i < missingKeys.Count; i += chunkSize)
{
var chunk = missingKeys.Skip(i).Take(chunkSize).ToList();
var sb = new StringBuilder();
sb.Append("SELECT Id, ");
sb.Append(mappedDestinationField);
sb.Append(" FROM ");
sb.Append(commonRequest.DestinationEntity);
sb.Append(" WHERE ");
sb.Append(mappedDestinationField);
sb.Append(" IN (");
sb.Append(string.Join(",", chunk.Select(v => $"'{v.Replace("'", "\\'")}'")));
sb.Append(')');
queries.Add(sb.ToString());
}
_logger.LogInformation("BULK PRE-DISCOVERY: {QueryCount} SOQL IN-query (~{ChunkSize} chiavi/query, Composite API ceil(N/25) HTTP call)",
queries.Count, chunkSize);
// BatchExecuteQueriesAsync raggruppa fino a 25 query in 1 Composite request
var batchResults = await sfClient.BatchExecuteQueriesAsync(queries);
// Indicizza i risultati per chiave: dal record letto leggiamo il valore di mappedDestinationField
var entityIdByKey = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var batchResult in batchResults.Where(r => r.Success && r.Records != null))
{
foreach (var record in batchResult.Records)
{
if (!record.TryGetValue(mappedDestinationField, out var keyVal) || keyVal == null)
continue;
var keyStr = keyVal.ToString();
if (string.IsNullOrEmpty(keyStr))
continue;
var idStr = ExtractDestinationId(record);
if (string.IsNullOrEmpty(idStr))
continue;
// In caso di duplicati in Salesforce, prendiamo il primo
if (!entityIdByKey.ContainsKey(keyStr))
entityIdByKey[keyStr] = idStr;
}
}
_logger.LogInformation("BULK PRE-DISCOVERY: trovati {Found}/{Missing} record esistenti nella destinazione",
entityIdByKey.Count, missingKeys.Count);
if (entityIdByKey.Count == 0)
return output;
// Salvataggio associazioni Pre-Discovery in parallelo
var saveTasks = entityIdByKey.Select(async kvp =>
{
try
{
var newAssoc = new KeyAssociation
{
KeyValue = kvp.Key,
SourceKeyField = commonRequest.SourceKeyField,
DestinationKeyField = commonRequest.DestinationKeyField ?? "Id",
MappedDestinationField = mappedDestinationField,
DestinationEntity = commonRequest.DestinationEntity,
DestinationId = kvp.Value,
RestCredentialName = commonRequest.CredentialName,
CreatedAt = DateTime.UtcNow,
LastVerifiedAt = DateTime.UtcNow,
IsActive = true,
AdditionalInfo = JsonSerializer.Serialize(new Dictionary<string, object>
{
{ "CreatedBy", "PreDiscovery" },
{ "DiscoveredAt", DateTime.UtcNow },
{ "MappingCount", commonRequest.FieldMappings.Count },
{ "BulkPreDiscovery", true },
{ "ScheduledTransfer", commonRequest.IsScheduledTransfer },
{ "SourceType", commonRequest.SourceType ?? string.Empty }
})
};
var id = commonRequest.UseParallelMethod
? await _credentialService.SaveKeyAssociationParallelAsync(newAssoc)
: await _credentialService.SaveKeyAssociationAsync(newAssoc);
newAssoc.Id = id;
return (kvp.Key, newAssoc);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "BULK PRE-DISCOVERY: errore nel salvataggio associazione per KeyValue '{KeyValue}'", kvp.Key);
return (kvp.Key, (KeyAssociation?)null);
}
});
var savedResults = await Task.WhenAll(saveTasks);
foreach (var (key, assoc) in savedResults)
{
if (assoc != null) output[key] = assoc;
}
return output;
}
private static PreDiscoveryRequest ClonePreDiscoveryRequest(PreDiscoveryRequest source, string sourceKey)
{
return new PreDiscoveryRequest
{
SourceKey = sourceKey,
SourceKeyField = source.SourceKeyField,
DestinationEntity = source.DestinationEntity,
CredentialName = source.CredentialName,
DestinationKeyField = source.DestinationKeyField,
FieldMappings = source.FieldMappings,
RestClient = source.RestClient,
CurrentDataHash = source.CurrentDataHash,
EnablePreDiscovery = source.EnablePreDiscovery,
UseParallelMethod = source.UseParallelMethod,
IsScheduledTransfer = source.IsScheduledTransfer,
SourceType = source.SourceType
};
}
/// <summary> /// <summary>
/// Verifica se un'associazione è stata creata dal Pre-Discovery /// Verifica se un'associazione è stata creata dal Pre-Discovery
/// controllando il campo AdditionalInfo /// controllando il campo AdditionalInfo
@@ -285,6 +508,22 @@ public interface IAssociationService
{ {
Task<KeyAssociation?> FindOrCreateAssociationAsync(PreDiscoveryRequest request); Task<KeyAssociation?> FindOrCreateAssociationAsync(PreDiscoveryRequest request);
bool IsPreDiscoveryAssociation(KeyAssociation association); bool IsPreDiscoveryAssociation(KeyAssociation association);
/// <summary>
/// Versione bulk del find-or-create.
/// 1) Una sola query SQLite (WHERE KeyValue IN …) per recuperare le associazioni esistenti.
/// 2) Per le chiavi non trovate localmente, una manciata di SOQL "IN" su Salesforce
/// (~200 chiavi per query, Composite API: ceil(K/25) HTTP call) invece di K chiamate singole.
/// 3) Le associazioni Pre-Discovery scoperte vengono salvate e restituite.
/// </summary>
/// <param name="sourceKeys">Lista (non vuota) dei valori chiave sorgente per tutti i record da analizzare.</param>
/// <param name="commonRequest">Parametri condivisi (entity, credential, restClient, mappings, ecc.).
/// <see cref="PreDiscoveryRequest.SourceKey"/> e <see cref="PreDiscoveryRequest.CurrentDataHash"/>
/// sono ignorati; vengono presi dal parametro <paramref name="sourceKeys"/>.</param>
/// <returns>Dizionario KeyValue → KeyAssociation (solo per chiavi trovate/create).</returns>
Task<Dictionary<string, KeyAssociation>> BatchFindOrCreateAssociationsAsync(
IEnumerable<string> sourceKeys,
PreDiscoveryRequest commonRequest);
} }
/// <summary> /// <summary>
+14 -2
View File
@@ -83,6 +83,14 @@ namespace Data_Coupler.Services
return new DataConnection.DB.OdbcDatabaseManager(connectionString); return new DataConnection.DB.OdbcDatabaseManager(connectionString);
} }
// Per OLE DB, usa OleDbDatabaseManager direttamente (EF Core non supporta OLE DB)
if (credential.DatabaseType == DatabaseType.OleDb)
{
var connectionString = CredentialManager.Models.ConnectionStringBuilder.BuildConnectionString(credential);
_logger.LogInformation("Creando OleDbDatabaseManager con connection string per {CredentialName}", credentialName);
return new DataConnection.DB.OleDbDatabaseManager(connectionString);
}
// Per altri database, usa EFCoreDatabaseManager // Per altri database, usa EFCoreDatabaseManager
var dbManagerOptions = await _credentialService.GetDbManagerOptionsAsync(credential.Name); var dbManagerOptions = await _credentialService.GetDbManagerOptionsAsync(credential.Name);
return new EFCoreDatabaseManager(dbManagerOptions); return new EFCoreDatabaseManager(dbManagerOptions);
@@ -133,7 +141,9 @@ namespace Data_Coupler.Services
// Per Salesforce usiamo i campi specifici ClientId e ClientSecret // Per Salesforce usiamo i campi specifici ClientId e ClientSecret
options.ApiKey = credential.ClientId; // ClientId -> ApiKey options.ApiKey = credential.ClientId; // ClientId -> ApiKey
options.AuthToken = credential.ClientSecret; // ClientSecret -> AuthToken options.AuthToken = credential.ClientSecret; // ClientSecret -> AuthToken
_logger.LogInformation("Salesforce mapping - ClientId: '{ClientId}', ClientSecret: {HasSecret}, Username: '{Username}', Password: {HasPassword}", options.SalesforceGrantType = credential.GrantType;
_logger.LogInformation("Salesforce mapping - GrantType: {GrantType}, ClientId: '{ClientId}', ClientSecret: {HasSecret}, Username: '{Username}', Password: {HasPassword}",
credential.GrantType,
credential.ClientId, credential.ClientId,
!string.IsNullOrEmpty(credential.ClientSecret), !string.IsNullOrEmpty(credential.ClientSecret),
credential.Username, credential.Username,
@@ -215,7 +225,9 @@ namespace Data_Coupler.Services
{ {
var httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>(); var httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient(); var httpClient = httpClientFactory.CreateClient();
return new SalesforceServiceClient(httpClient, options); var loggerFactory = _serviceProvider.GetService<Microsoft.Extensions.Logging.ILoggerFactory>();
var sfLogger = loggerFactory?.CreateLogger<DataConnection.REST.Implementations.SalesforceServiceClient>();
return new DataConnection.REST.Implementations.SalesforceServiceClient(httpClient, options, sfLogger);
} }
/// <summary> /// <summary>
+189 -69
View File
@@ -1,5 +1,6 @@
using CredentialManager.Models; using CredentialManager.Models;
using DataConnection.CredentialManagement.Interfaces; using DataConnection.CredentialManagement.Interfaces;
using DataConnection.REST.Implementations;
using DataConnection.REST.Interfaces; using DataConnection.REST.Interfaces;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -79,76 +80,18 @@ public class DeletionSyncService : IDeletionSyncService
_logger.LogInformation("Trovate {Count} cancellazioni in attesa di sincronizzazione", _logger.LogInformation("Trovate {Count} cancellazioni in attesa di sincronizzazione",
pendingDeletions.Count); pendingDeletions.Count);
// Step 3: Esegui le cancellazioni nella destinazione // Step 3: Esegui le cancellazioni nella destinazione.
foreach (var deletion in pendingDeletions) // Per Salesforce usiamo le Composite API in batch (ceil(N/25) HTTP call invece di N);
// per gli altri client REST manteniamo il loop sequenziale (nessun batch supportato).
if (restClient is SalesforceServiceClient salesforceClient)
{ {
try await ExecuteBatchedSalesforceDeletionsAsync(
{ salesforceClient, destinationEntity, pendingDeletions, options, result);
bool syncSuccess = false; }
string errorMessage = ""; else
{
switch (options.Action) await ExecuteSequentialDeletionsAsync(
{ restClient, destinationEntity, pendingDeletions, options, result);
case DeletionAction.Delete:
// Elimina fisicamente il record
syncSuccess = await DeleteRecordAsync(
restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Deactivate:
// Marca il record come inattivo
syncSuccess = await DeactivateRecordAsync(
restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Mark:
// Imposta un campo personalizzato
if (string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue))
{
errorMessage = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
syncSuccess = await MarkRecordAsync(
restClient, destinationEntity, deletion.DestinationId,
options.MarkField, options.MarkValue);
break;
default:
errorMessage = $"Azione di cancellazione non supportata: {options.Action}";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
if (syncSuccess)
{
// Marca la cancellazione come sincronizzata
await _credentialService.MarkDeletionSyncedAsync(deletion.Id);
result.DeletedRecordsSynced++;
_logger.LogInformation(
"Cancellazione sincronizzata: KeyValue={KeyValue}, DestinationId={DestinationId}, Action={Action}",
deletion.KeyValue, deletion.DestinationId, options.Action);
}
else
{
result.SyncErrors++;
var error = $"Errore nella sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue}";
result.Errors.Add(error);
_logger.LogWarning(error);
}
}
catch (Exception ex)
{
result.SyncErrors++;
var error = $"Errore durante la sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue} - {ex.Message}";
result.Errors.Add(error);
_logger.LogError(ex, "Errore nella sincronizzazione della cancellazione per {KeyValue}",
deletion.KeyValue);
}
} }
result.IsSuccess = result.SyncErrors == 0; result.IsSuccess = result.SyncErrors == 0;
@@ -170,6 +113,183 @@ public class DeletionSyncService : IDeletionSyncService
return result; return result;
} }
/// <summary>
/// Esegue le cancellazioni in batch via Salesforce Composite API.
/// Riduce N round-trip HTTP a ceil(N/25) batch in parallelo.
/// </summary>
private async Task ExecuteBatchedSalesforceDeletionsAsync(
SalesforceServiceClient salesforceClient,
string destinationEntity,
List<KeyAssociation> pendingDeletions,
DeletionSyncOptions options,
DeletionSyncResult result)
{
// Per Mark serve MarkField e MarkValue: validazione preventiva (un solo log)
if (options.Action == DeletionAction.Mark &&
(string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue)))
{
const string err = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(err);
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - {err}");
}
return;
}
// Mappa entityId → KeyAssociation per ricostruire l'associazione dal risultato batch
var deletionsById = pendingDeletions
.Where(d => !string.IsNullOrEmpty(d.DestinationId))
.GroupBy(d => d.DestinationId)
.ToDictionary(g => g.Key, g => g.First()); // se duplicati, prima occorrenza
var entityIds = deletionsById.Keys.ToList();
if (entityIds.Count == 0)
return;
_logger.LogInformation("DELETION SYNC (Salesforce batched): {Count} record, action={Action}",
entityIds.Count, options.Action);
List<DataConnection.REST.Implementations.SalesforceServiceClient.CompositeOperationResult> batchResults;
try
{
switch (options.Action)
{
case DeletionAction.Delete:
batchResults = await salesforceClient.BatchDeleteEntitiesAsync(destinationEntity, entityIds);
break;
case DeletionAction.Deactivate:
// Aggiorna IsActive/Active = false in batch.
// Non sappiamo a priori quale dei due campi esista sull'SObject: proviamo IsActive,
// se Salesforce ritorna errore il record verrà segnalato come fallito.
var deactivateUpdates = entityIds.ToDictionary(
id => id,
_ => (Dictionary<string, object>)new Dictionary<string, object>
{
{ "IsActive", false }
});
batchResults = await salesforceClient.BatchUpdateEntitiesAsync(destinationEntity, deactivateUpdates);
break;
case DeletionAction.Mark:
batchResults = await salesforceClient.BatchPatchSingleFieldAsync(
destinationEntity, entityIds, options.MarkField!, options.MarkValue!);
break;
default:
_logger.LogWarning("DELETION SYNC: azione non supportata: {Action}", options.Action);
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - Azione non supportata: {options.Action}");
}
return;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "DELETION SYNC: errore nell'esecuzione del batch Salesforce");
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - {ex.Message}");
}
return;
}
// Aggiorna lo stato delle cancellazioni in DB in parallelo per i record sincronizzati con successo
var markSyncedTasks = new List<Task>();
foreach (var br in batchResults)
{
if (!deletionsById.TryGetValue(br.EntityId ?? string.Empty, out var deletion))
continue;
if (br.Success)
{
result.DeletedRecordsSynced++;
markSyncedTasks.Add(_credentialService.MarkDeletionSyncedAsync(deletion.Id));
_logger.LogDebug(
"DELETION SYNC: KeyValue={KeyValue}, DestinationId={DestinationId}, Action={Action} OK",
deletion.KeyValue, deletion.DestinationId, options.Action);
}
else
{
result.SyncErrors++;
var msg = $"KeyValue: {deletion.KeyValue} - {br.ErrorMessage ?? "Unknown error"}";
result.Errors.Add(msg);
_logger.LogWarning("DELETION SYNC fallita: {Msg}", msg);
}
}
if (markSyncedTasks.Count > 0)
await Task.WhenAll(markSyncedTasks);
}
/// <summary>
/// Fallback sequenziale per client REST non Salesforce.
/// </summary>
private async Task ExecuteSequentialDeletionsAsync(
IRestServiceClient restClient,
string destinationEntity,
List<KeyAssociation> pendingDeletions,
DeletionSyncOptions options,
DeletionSyncResult result)
{
foreach (var deletion in pendingDeletions)
{
try
{
bool syncSuccess = false;
string errorMessage = "";
switch (options.Action)
{
case DeletionAction.Delete:
syncSuccess = await DeleteRecordAsync(restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Deactivate:
syncSuccess = await DeactivateRecordAsync(restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Mark:
if (string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue))
{
errorMessage = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
syncSuccess = await MarkRecordAsync(restClient, destinationEntity, deletion.DestinationId,
options.MarkField, options.MarkValue);
break;
default:
errorMessage = $"Azione di cancellazione non supportata: {options.Action}";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
if (syncSuccess)
{
await _credentialService.MarkDeletionSyncedAsync(deletion.Id);
result.DeletedRecordsSynced++;
}
else
{
result.SyncErrors++;
result.Errors.Add($"Errore nella sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue}");
}
}
catch (Exception ex)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {ex.Message}");
_logger.LogError(ex, "Errore nella sincronizzazione della cancellazione per {KeyValue}", deletion.KeyValue);
}
}
}
/// <summary> /// <summary>
/// Elimina fisicamente un record dalla destinazione /// Elimina fisicamente un record dalla destinazione
/// </summary> /// </summary>
@@ -164,18 +164,32 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
throw new InvalidOperationException("Nessun mapping dei campi configurato per il profilo"); 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 // 5. Determina se utilizzare Salesforce Composite API
bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient; bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient;
if (useSalesforceComposite) if (useSalesforceComposite)
{ {
_logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento"); _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 else
{ {
_logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento"); _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) catch (Exception ex)
@@ -363,6 +377,100 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
return mappings; return mappings;
} }
/// <summary>
/// Deserializza gli External ID Relationships dal JSON del profilo
/// </summary>
private List<ExternalIdRelationshipDto> ParseExternalIdRelationships(string? externalIdRelationshipsJson)
{
var relationships = new List<ExternalIdRelationshipDto>();
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<List<ExternalIdRelationshipDto>>(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;
}
/// <summary>
/// Parse del JSON dei default values
/// </summary>
private Dictionary<string, (object? Value, string? Type)> ParseDefaultValues(string? defaultValuesJson)
{
var defaultValues = new Dictionary<string, (object? Value, string? Type)>();
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<Dictionary<string, DefaultValueDto>>(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;
}
/// <summary> /// <summary>
/// Ottiene tutti i record dal database /// Ottiene tutti i record dal database
/// </summary> /// </summary>
@@ -631,6 +739,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
RestEntitySummary restEntity, RestEntitySummary restEntity,
RestApiCredential restCredential, RestApiCredential restCredential,
Dictionary<string, string> fieldMappings, Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto> externalIdRelationships,
bool enableDeletionSync = false) bool enableDeletionSync = false)
{ {
_logger.LogInformation("Iniziando trasferimento dati standard per {RecordCount} record - DeletionSync: {DeletionSync}", _logger.LogInformation("Iniziando trasferimento dati standard per {RecordCount} record - DeletionSync: {DeletionSync}",
@@ -644,8 +754,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
{ {
try try
{ {
// 1. Trasforma il record utilizzando i field mappings // 1. Trasforma il record utilizzando i field mappings, default values e External ID Relationships
var restData = TransformRecordForRest(record, fieldMappings); var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships);
// 2. Gestione associazioni record se abilitata // 2. Gestione associazioni record se abilitata
string? entityId = null; string? entityId = null;
@@ -755,6 +865,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
RestEntitySummary restEntity, RestEntitySummary restEntity,
RestApiCredential restCredential, RestApiCredential restCredential,
Dictionary<string, string> fieldMappings, Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto> externalIdRelationships,
bool enableDeletionSync = false) bool enableDeletionSync = false)
{ {
_logger.LogInformation("Iniziando trasferimento dati COMPOSITE per {RecordCount} record - DeletionSync: {DeletionSync}", _logger.LogInformation("Iniziando trasferimento dati COMPOSITE per {RecordCount} record - DeletionSync: {DeletionSync}",
@@ -764,7 +876,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient)) if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient))
{ {
_logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard"); _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 try
@@ -783,10 +895,45 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
// Crea lista indicizzata per mantenere il record number // Crea lista indicizzata per mantenere il record number
var indexedRecords = sourceRecords.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList(); var indexedRecords = sourceRecords.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList();
_logger.LogInformation("COMPOSITE SCHEDULED: Inizio analisi parallela di {RecordCount} record", indexedRecords.Count); _logger.LogInformation("COMPOSITE SCHEDULED: Inizio analisi di {RecordCount} record", indexedRecords.Count);
var analysisStartTime = DateTime.UtcNow; var analysisStartTime = DateTime.UtcNow;
// Processa tutti i record in parallelo // === STEP A: Bulk Pre-Discovery (1 query SQLite + poche SOQL IN invece di 2N+N) ===
// Pre-calcolo locale: source key per ogni record (operazione thread-safe)
var sourceKeyByRecordIndex = new Dictionary<int, string>(indexedRecords.Count);
foreach (var idx in indexedRecords)
{
var key = GenerateSourceKey(idx.Record, profile.SourceKeyField);
if (!string.IsNullOrEmpty(key))
sourceKeyByRecordIndex[idx.RecordNumber] = key;
}
Dictionary<string, KeyAssociation> associationsByKey = new(StringComparer.Ordinal);
if (currentUseRecordAssociations && !string.IsNullOrEmpty(profile.SourceKeyField) && sourceKeyByRecordIndex.Count > 0)
{
var commonRequest = new PreDiscoveryRequest
{
SourceKeyField = profile.SourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = "Id",
FieldMappings = fieldMappings,
RestClient = restClient,
EnablePreDiscovery = true,
UseParallelMethod = true,
IsScheduledTransfer = true,
SourceType = profile.SourceType
};
associationsByKey = await _associationService.BatchFindOrCreateAssociationsAsync(
sourceKeyByRecordIndex.Values, commonRequest);
_logger.LogInformation("COMPOSITE SCHEDULED: Bulk Pre-Discovery completata - {Found}/{Total} associazioni risolte",
associationsByKey.Count, sourceKeyByRecordIndex.Count);
}
// === STEP B: Analisi locale parallela per decidere create/update/skip ===
// Nessuna chiamata DB o REST in questo loop — solo memoria.
var processingTasks = indexedRecords.Select(async indexedRecord => var processingTasks = indexedRecords.Select(async indexedRecord =>
{ {
try try
@@ -794,8 +941,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
var record = indexedRecord.Record; var record = indexedRecord.Record;
var recordNumber = indexedRecord.RecordNumber; var recordNumber = indexedRecord.RecordNumber;
// Trasforma il record in base ai mapping (operazione locale, thread-safe) // Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe)
var restData = TransformRecordForRest(record, fieldMappings); var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships);
// Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE) // Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE)
var sourceKey = GenerateSourceKey(record, profile.SourceKeyField); var sourceKey = GenerateSourceKey(record, profile.SourceKeyField);
@@ -804,49 +951,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
// Analizza le associazioni per capire se aggiornare, creare o saltare // Analizza le associazioni per capire se aggiornare, creare o saltare
if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey)) if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey))
{ {
_logger.LogDebug("COMPOSITE SCHEDULED: Cerco associazione per KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'", associationsByKey.TryGetValue(sourceKey, out var existingAssociation);
sourceKey, currentEntityName, currentCredentialName);
// Cerca associazione esistente usando il metodo parallelo
var existingAssociation = await _dataConnectionCredentialService.FindKeyAssociationByValueParallelAsync(
sourceKey, currentEntityName, currentCredentialName);
// FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue
if (existingAssociation == null)
{
existingAssociation = await _dataConnectionCredentialService.FindKeyAssociationByValueParallelAsync(sourceKey);
if (existingAssociation != null)
{
// Verifica compatibilità
if (existingAssociation.DestinationEntity != currentEntityName ||
existingAssociation.RestCredentialName != currentCredentialName)
{
existingAssociation = null;
}
}
}
// 🔍 PRE-DISCOVERY: Usa il servizio centralizzato
if (existingAssociation == null && !string.IsNullOrEmpty(profile.SourceKeyField))
{
var preDiscoveryRequest = new PreDiscoveryRequest
{
SourceKey = sourceKey,
SourceKeyField = profile.SourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = "Id",
FieldMappings = fieldMappings,
RestClient = restClient,
CurrentDataHash = currentDataHash,
EnablePreDiscovery = true,
UseParallelMethod = true, // Usa metodi paralleli thread-safe
IsScheduledTransfer = true,
SourceType = profile.SourceType
};
existingAssociation = await _associationService.FindOrCreateAssociationAsync(preDiscoveryRequest);
}
if (existingAssociation != null && existingAssociation.IsActive) if (existingAssociation != null && existingAssociation.IsActive)
{ {
@@ -1085,12 +1190,33 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
/// <summary> /// <summary>
/// Trasforma un record sorgente in formato REST utilizzando i field mappings /// Trasforma un record sorgente in formato REST utilizzando i field mappings
/// </summary> /// </summary>
private Dictionary<string, object> TransformRecordForRest(Dictionary<string, object> sourceRecord, Dictionary<string, string> fieldMappings) private Dictionary<string, object> TransformRecordForRest(
Dictionary<string, object> sourceRecord,
Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto>? externalIdRelationships = null)
{ {
var restData = new Dictionary<string, object>(); var restData = new Dictionary<string, object>();
// 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<string>();
// 1. Applica field mappings (escludendo i campi sorgente usati per External ID Relationships)
foreach (var mapping in fieldMappings) 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)) if (sourceRecord.ContainsKey(mapping.Key))
{ {
var value = sourceRecord[mapping.Key]; var value = sourceRecord[mapping.Key];
@@ -1105,6 +1231,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<string, object>
{
{ relationship.ExternalIdField, transformedValue }
};
restData[relationship.RelationshipName] = externalIdObject;
_logger.LogDebug("Aggiunta External ID Relationship: {RelationshipName} → {ExternalIdField} = {Value}",
relationship.RelationshipName, relationship.ExternalIdField, transformedValue);
}
}
}
}
return restData; return restData;
} }
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
{
"version": "2.2.0",
"commitSha": "01f7846",
"branch": "development",
"buildDate": "2026-02-02",
"buildEnvironment": "Local"
}
+17 -8
View File
@@ -3,6 +3,8 @@
# Stage 1: Build # Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
# Versione calcolata da MinVer sul runner CI/CD e passata come build-arg
ARG APP_VERSION=0.0.0-alpha.0
WORKDIR /src WORKDIR /src
# Copia i file di progetto e ripristina le dipendenze # Copia i file di progetto e ripristina le dipendenze
@@ -20,24 +22,31 @@ COPY . .
# Build del progetto principale # Build del progetto principale
WORKDIR "/src/Data_Coupler" WORKDIR "/src/Data_Coupler"
RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=${APP_VERSION}
# Stage 2: Publish # Stage 2: Publish
FROM build AS publish FROM build AS publish
# Necessario ridichiarare ARG dopo FROM in multi-stage build
ARG APP_VERSION=0.0.0-alpha.0
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /app/publish \ RUN dotnet publish "Data_Coupler.csproj" -c Release -o /app/publish \
/p:UseAppHost=false \ /p:SelfContained=false \
/p:PublishTrimmed=false \ /p:PublishTrimmed=false \
/p:PublishSingleFile=false /p:PublishSingleFile=false \
/p:ContinuousIntegrationBuild=true \
/p:MinVerVersionOverride=${APP_VERSION}
# Stage 3: Runtime # Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app WORKDIR /app
# Installa le dipendenze necessarie per ExcelDataReader e altre librerie # Installa le dipendenze necessarie per ExcelDataReader e SQLite
RUN apk add --no-cache \ RUN apt-get update && apt-get install -y \
libgdiplus \ libgdiplus \
icu-libs \ libc6-dev \
&& rm -rf /var/cache/apk/* sqlite3 \
libsqlite3-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Crea la directory per il database con i permessi corretti # Crea la directory per il database con i permessi corretti
RUN mkdir -p /var/lib/Data_Coupler && \ RUN mkdir -p /var/lib/Data_Coupler && \
+7 -3
View File
@@ -3,6 +3,8 @@
# Stage 1: Build # Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:9.0-nanoserver-ltsc2022 AS build FROM mcr.microsoft.com/dotnet/sdk:9.0-nanoserver-ltsc2022 AS build
# Versione calcolata da MinVer sul runner CI/CD e passata come build-arg
ARG APP_VERSION=0.0.0-alpha.0
WORKDIR /s WORKDIR /s
# Copia i file di progetto e ripristina le dipendenze con nomi originali # Copia i file di progetto e ripristina le dipendenze con nomi originali
@@ -13,7 +15,7 @@ COPY ["Components/Components.csproj", "Components/"]
COPY ["nuget.config", "./"] COPY ["nuget.config", "./"]
# Ripristina le dipendenze per tutti i progetti con package cache ultra-corto # Ripristina le dipendenze per tutti i progetti con package cache ultra-corto
RUN dotnet restore "Data_Coupler/Data_Coupler.csproj" --disable-parallel --packages /p RUN dotnet restore "Data_Coupler/Data_Coupler.csproj" --runtime win-x64 --disable-parallel --packages /p
# Copia tutto il codice sorgente # Copia tutto il codice sorgente
COPY ["Data_Coupler/", "Data_Coupler/"] COPY ["Data_Coupler/", "Data_Coupler/"]
@@ -23,11 +25,13 @@ COPY ["Components/", "Components/"]
# Build del progetto principale con output path corto # Build del progetto principale con output path corto
WORKDIR "/s/Data_Coupler" WORKDIR "/s/Data_Coupler"
RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=%APP_VERSION%
# Stage 2: Publish # Stage 2: Publish
FROM build AS publish FROM build AS publish
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore /p:UseAppHost=false # Necessario ridichiarare ARG dopo FROM in multi-stage build
ARG APP_VERSION=0.0.0-alpha.0
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore -r win-x64 --self-contained false /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=%APP_VERSION%
# Stage 3: Runtime # Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS final FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS final
+452
View File
@@ -0,0 +1,452 @@
# Implementazione External ID Relationships per Salesforce
## 📋 Panoramica
Implementata la funzionalità completa per gestire **External ID Relationships** nell'interfaccia di mapping dei campi di Data-Coupler. Questa feature permette di creare relazioni tra oggetti Salesforce utilizzando External ID durante il trasferimento dati, evitando la necessità di conoscere gli ID Salesforce interni.
## 🎯 Obiettivi Raggiunti
- ✅ Estensione modelli dati (DTO ed Entity) per supportare External ID Relationships
- ✅ UI completa per configurazione relazioni con autocomplete
- ✅ Logica di trasformazione dati integrata in DataCoupler e ScheduledProfileExecutionService
- ✅ Supporto per salvataggio e caricamento relazioni in profili Data Coupler
- ✅ Migrazione database per persistenza configurazioni
- ✅ Supporto per esecuzioni schedulate
## 🏗️ Architettura Implementata
### 1. Modelli Dati
#### **ExternalIdRelationshipDto** (CredentialManager/Models/DataCouplerProfileDto.cs)
```csharp
public class ExternalIdRelationshipDto
{
public string RelationshipName { get; set; } = string.Empty; // Es: "Account__r"
public string RelatedObjectName { get; set; } = string.Empty; // Es: "Account"
public string ExternalIdField { get; set; } = string.Empty; // Es: "Country__c"
public string SourceField { get; set; } = string.Empty; // Campo sorgente con valore
}
```
#### **DataCouplerProfile Entity** (CredentialManager/Models/DataCouplerProfile.cs)
```csharp
[MaxLength(4000)]
public string? ExternalIdRelationshipsJson { get; set; }
```
### 2. Serializzazione/Deserializzazione
#### **DataCouplerProfileService** (CredentialManager/Services/DataCouplerProfileService.cs)
**Metodi Aggiunti:**
- `SerializeExternalIdRelationships()` - Serializza lista DTO → JSON
- `DeserializeExternalIdRelationships()` - Deserializza JSON → lista DTO
- Aggiornato `ToDto()` per includere External ID Relationships
- Aggiornato `FromDto()` per serializzare relazioni
- Aggiornato `UpdateProfileAsync()` per persistere ExternalIdRelationshipsJson
### 3. Interfaccia Utente
#### **DataCoupler.razor** - Sezione External ID Relationships
**Componenti UI:**
1. **Selezione Oggetto Correlato**: Dropdown con tutti gli oggetti REST disponibili
2. **Selezione External ID Field**: Dropdown con campi filtrati (terminanti con `__c`, `Id`, contengono "External")
3. **Selezione Campo Sorgente**: Dropdown con campi disponibili dalla sorgente dati
4. **Pulsante Aggiungi**: Conferma e aggiunge relazione alla lista
5. **Tabella Relazioni**: Visualizza tutte le relazioni configurate con formato di esempio
**Visibilità Condizionale:**
```csharp
@if (fieldMappings.Any() && currentRestDiscovery != null && IsSalesforceClient())
```
- Mostrata solo per connessioni Salesforce
- Solo dopo aver configurato i field mappings principali
#### **DataCoupler.razor.cs** - Gestione Relazioni
**Campi Aggiunti:**
```csharp
private List<ExternalIdRelationshipDto> externalIdRelationships = new();
private string selectedRelationshipObject = string.Empty;
private string selectedExternalIdField = string.Empty;
private string selectedRelationshipSourceField = string.Empty;
private List<RestEntityInfo> availableRelationshipObjects = new();
```
**Metodi Implementati:**
- `OnRelationshipObjectSelected()` - Gestisce selezione oggetto
- `AddExternalIdRelationship()` - Aggiunge nuova relazione con validazione
- `RemoveExternalIdRelationship()` - Rimuove relazione esistente
- `GetExternalIdFieldsForSelectedObject()` - Ottiene campi External ID disponibili
- `GetSourceFieldsForRelationship()` - Ottiene campi sorgente per mapping
**Integrazione Reset/Clear:**
- Aggiornato `ClearAllMappings()` per pulire relazioni
- Aggiornato `ResetAllState()` per reset completo
- Aggiornato `ApplyProfileConfiguration()` per caricare relazioni da profilo
### 4. Trasformazione Dati
#### **DataCoupler.razor.cs** - TransformRecordToRestEntity()
```csharp
// 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)
{
// Formato: { "Account__r": { "Country__c": "US" } }
var externalIdObject = new Dictionary<string, object>
{
{ relationship.ExternalIdField, transformedValue }
};
restData[relationship.RelationshipName] = externalIdObject;
}
}
}
}
```
#### **ScheduledProfileExecutionService** - TransformRecordForRest()
**Modifiche:**
- Aggiunto parametro opzionale `List<ExternalIdRelationshipDto>? externalIdRelationships`
- Implementata stessa logica di trasformazione per esecuzioni schedulate
- Aggiornato `ExecuteDataTransferAsync()` per deserializzare e passare relazioni
- Aggiornato `ExecuteDataTransferStandardAsync()` per accettare e usare relazioni
- Aggiornato `ExecuteDataTransferWithCompositeAsync()` per supporto Salesforce Composite API
**Nuovo Metodo:**
```csharp
private List<ExternalIdRelationshipDto> ParseExternalIdRelationships(string? externalIdRelationshipsJson)
{
// Deserializza JSON con stesse opzioni di DataCouplerProfileService
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return JsonSerializer.Deserialize<List<ExternalIdRelationshipDto>>(externalIdRelationshipsJson, options);
}
```
### 5. Salvataggio Profili
#### **Components/ProfileSaver.razor.cs**
**Modifiche:**
- Aggiunto parametro `ExternalIdRelationships`
- Incluso nella creazione del DTO per salvataggio profili
```csharp
[Parameter]
public List<ExternalIdRelationshipDto> ExternalIdRelationships { get; set; } = new();
// In SaveProfile()
ExternalIdRelationships = this.ExternalIdRelationships,
```
### 6. Discovery REST API
#### **Data_Coupler/Extensions/DataCoupler/RESTMethod.cs**
**Modifiche:**
- Aggiornato `ConnectToRestApi()` per popolare `availableRelationshipObjects`
- Chiamata a `DiscoverEntitiesAsync()` per ottenere dettagli completi oggetti REST
```csharp
try
{
availableRelationshipObjects = (await currentRestDiscovery.DiscoverEntitiesAsync()).ToList();
Logger.LogInformation("Caricati {Count} oggetti REST per External ID Relationships", availableRelationshipObjects.Count);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Impossibile caricare oggetti REST per External ID Relationships");
}
```
### 7. Migrazione Database
#### **File Creati:**
1. **20260203000000_AddExternalIdRelationships.cs**
- Migrazione Entity Framework per aggiungere campo `ExternalIdRelationshipsJson`
- Tipo: TEXT, MaxLength: 4000, Nullable
2. **20260203000000_AddExternalIdRelationships.sql**
- Script SQL manuale per applicazione diretta se necessario
- Include update di `__EFMigrationsHistory`
```sql
ALTER TABLE DataCouplerProfiles ADD COLUMN ExternalIdRelationshipsJson TEXT;
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion)
VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
```
## 📊 Formato Dati Salesforce
### ⚠️ REGOLA IMPORTANTE: Formato Basato sull'Oggetto DESTINAZIONE
Il formato delle External ID Relationships dipende dal **tipo dell'oggetto DESTINAZIONE** (quello che stai creando/aggiornando), **NON** dal tipo dell'oggetto correlato:
#### **Se l'Oggetto DESTINAZIONE è CUSTOM** (es. `Sales_Quote__c`, `Custom_Order__c`):
- ✅ Tutte le relazioni usano `__r`, sia per oggetti standard che custom
- **Oggetto Standard**: `"Account__r": { "External_ID__c": "value" }`
- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }`
#### **Se l'Oggetto DESTINAZIONE è STANDARD** (es. `Opportunity`, `Contact`):
- ✅ Solo oggetti custom correlati usano `__r`
- **Oggetto Standard**: `"Account": { "External_ID__c": "value" }`
- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }`
### Esempi Pratici
#### Esempio 1: Destinazione CUSTOM → Relazione a Oggetto STANDARD
**Scenario**: Creo un record `Sales_Quote__c` collegato ad `Account` standard
**Configurazione:**
- **Destination Object**: `Sales_Quote__c` (CUSTOM)
- **Relationship Name**: `Account__r` ⚠️ **Usa __r anche se Account è standard!**
- **Related Object**: `Account`
- **External ID Field**: `Codice_ERP__c`
- **Source Field**: `customerCode`
**Record Trasformato:**
```json
{
"Name": "Quote 2024-001",
"Quote_Code__c": "Q001",
"Account__r": {
"Codice_ERP__c": "C60000"
}
}
```
#### Esempio 2: Destinazione STANDARD → Relazione a Oggetto STANDARD
**Scenario**: Creo un record `Opportunity` collegato ad `Account`
**Configurazione:**
- **Destination Object**: `Opportunity` (STANDARD)
- **Relationship Name**: `Account` ⚠️ **NON usa __r**
- **Related Object**: `Account`
- **External ID Field**: `Country__c`
- **Source Field**: `CountryCode`
**Record Trasformato:**
```json
{
"Name": "New Deal 2024",
"StageName": "Prospecting",
"Account": {
"Country__c": "US"
}
}
```
#### Esempio 3: Destinazione CUSTOM → Relazione a Oggetto CUSTOM
**Configurazione:**
- **Destination Object**: `Sales_Quote__c` (CUSTOM)
- **Relationship Name**: `Custom_Territory__r`
- **Related Object**: `Custom_Territory__c`
- **External ID Field**: `Territory_Code__c`
- **Source Field**: `territoryCode`
**Record Sorgente:**
```json
{
"quoteName": "Quote A",
"territoryCode": "NORTH-WEST"
}
```
**Record Trasformato:**
```json
{
"Name": "Quote A",
"Custom_Territory__r": {
"Territory_Code__c": "NORTH-WEST"
}
}
```
### Logica di Normalizzazione Automatica
Il sistema implementa il metodo `NormalizeRelationshipName()` che garantisce il formato corretto:
```csharp
private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom)
{
if (isDestinationCustom)
{
// Destinazione CUSTOM: tutte le relazioni usano __r
if (relatedObjectName.EndsWith("__c"))
return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r
else
return relatedObjectName + "__r"; // Account → Account__r
}
else
{
// Destinazione STANDARD: solo oggetti custom usano __r
if (relatedObjectName.EndsWith("__c"))
return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r
else
return relatedObjectName; // Account → Account (no suffix)
}
}
```
### Vantaggi External ID
1. **Nessun ID Salesforce Richiesto**: Non serve conoscere l'ID Salesforce dell'Account
2. **Lookup Automatico**: Salesforce cerca automaticamente l'oggetto correlato tramite External ID
3. **Upsert Intelligente**: Se non trova l'oggetto, può crearlo automaticamente (se configurato)
4. **Manutenzione Semplificata**: I codici esterni sono più stabili degli ID interni
5. **Normalizzazione Automatica**: Il sistema corregge automaticamente i nomi quando carica profili salvati
## 🔄 Flusso Operativo
### Configurazione Manuale (DataCoupler.razor)
1. Utente configura connessione sorgente (database/file) e destinazione (Salesforce)
2. Sistema scopre automaticamente oggetti REST disponibili
3. Utente configura field mappings principali
4. Sezione External ID Relationships diventa visibile
5. Utente seleziona:
- Oggetto correlato (es: Account)
- Campo External ID (es: Country__c)
- Campo sorgente (es: CountryCode)
6. Click su "Aggiungi Relazione" → validazione e aggiunta alla lista
7. (Opzionale) Salvataggio come profilo per riutilizzo futuro
8. Esecuzione trasferimento → relazioni applicate automaticamente
### Esecuzione Schedulata (ScheduledProfileExecutionService)
1. Background service carica profilo dal database
2. Deserializza External ID Relationships da JSON
3. Estrae dati dalla sorgente
4. Trasforma ogni record applicando field mappings + External ID Relationships
5. Invia a Salesforce (Standard API o Composite API)
6. Gestisce associazioni record e hash per evitare duplicati
## 🧪 Testing
### Scenari di Test Consigliati
1. **Configurazione UI**
- ✅ Selezione oggetti e campi funziona correttamente
- ✅ Validazione impedisce relazioni incomplete
- ✅ Aggiunta e rimozione relazioni aggiorna UI
2. **Salvataggio/Caricamento Profili**
- ✅ Relazioni salvate correttamente in JSON
- ✅ Profilo ricaricato ripristina tutte le relazioni
- ✅ Database persiste ExternalIdRelationshipsJson
3. **Trasformazione Dati**
- ✅ Record trasformato include dizionario annidato per relazioni
- ✅ Valori null/vuoti gestiti correttamente
- ✅ Logging dettagliato per ogni relazione aggiunta
4. **Esecuzione Schedulata**
- ✅ Schedulazione carica e applica relazioni
- ✅ Funziona sia con Standard API che Composite API
- ✅ Errori gestiti e loggati senza bloccare il flusso
5. **Integrazione Salesforce**
- ✅ Salesforce accetta formato External ID Relationship
- ✅ Lookup automatico funziona correttamente
- ✅ Record creati con relazioni corrette
## 📝 Note Implementative
### Decisioni di Design
1. **MaxLength JSON: 4000 caratteri**
- Ragionamento: Supporta configurazioni complesse senza eccedere limiti SQLite
- Alternativa: Se necessario più spazio, può essere aumentato a TEXT illimitato
2. **Parametro Opzionale in TransformRecordForRest**
- Backward compatibility garantita
- Chiamate esistenti senza External ID continuano a funzionare
3. **Filtro Campi External ID**
- Logica: `EndsWith("__c") || Name == "Id" || Contains("External")`
- Copre la maggior parte dei casi comuni in Salesforce
- Personalizzabile se necessario
4. **Visibilità Condizionale UI**
- Solo per Salesforce (verifica `IsSalesforceClient()`)
- Solo dopo field mappings configurati (`fieldMappings.Any()`)
- Migliora UX evitando confusione per altre API
5. **⭐ Normalizzazione Automatica RelationshipName (FIX CRITICO - 17 Feb 2026)**
- **Problema Risolto**: Errore `"No such column 'Account' on sobject of type Sales_Quote__c"`
- **Causa**: Il formato dipende dall'oggetto DESTINAZIONE, non dall'oggetto correlato
- **Soluzione**: Metodo `NormalizeRelationshipName()` controlla tipo oggetto destinazione
- **Funzionalità**: Corregge automaticamente i RelationshipName al caricamento profili
- **Regola**: Se destinazione è custom → usa SEMPRE `__r` per tutte le relazioni
- **Benefici**: Profili esistenti vengono corretti automaticamente senza intervento manuale
### Potenziali Estensioni Future
1. **Validazione Avanzata**: Verifica esistenza oggetto/campo su Salesforce prima di salvare
2. **Multi-Level Relationships**: Supporto per relazioni annidate (es: `Account__r.Owner__r.Name__c`)
3. **Relazioni Composite**: Più External ID per stesso oggetto (es: FirstName + LastName)
4. **Import/Export Relazioni**: Backup e restore separato delle configurazioni relazioni
5. **Template Relazioni**: Libreria di relazioni predefinite per oggetti Salesforce comuni
## 🐛 Troubleshooting
### Errori Comuni
**⚠️ Errore: "No such column 'Account' on sobject of type Sales_Quote__c"**
- **Causa**: RelationshipName incorretto per oggetto destinazione custom
- **Spiegazione**: Quando l'oggetto DESTINAZIONE è custom (es. `Sales_Quote__c`), TUTTE le relazioni devono usare `__r`, anche per oggetti standard
- **Soluzione AUTOMATICA**: ✅ Il sistema ora normalizza automaticamente i nomi delle relazioni
- **Esempio**: Se destinazione è `Sales_Quote__c` e correlato è `Account` → usa `Account__r` (non `Account`)
- **Fix Manuale**: Se usi profili vecchi, il sistema correggerà automaticamente al caricamento
**Errore: "External ID field not found"**
- Causa: Campo External ID non esiste sull'oggetto Salesforce
- Soluzione: Verificare che il campo sia configurato come External ID in Salesforce
**Errore: "Multiple records found with external ID"**
- Causa: External ID non è univoco in Salesforce
- Soluzione: Verificare unicità del campo External ID
**Relazioni Non Applicate**
- Causa: `externalIdRelationships` è vuoto
- Soluzione: Verificare deserializzazione JSON in profilo
**UI Non Mostra Sezione Relazioni**
- Causa: Condizione visibilità non soddisfatta
- Soluzione: Verificare che sia Salesforce e field mappings configurati
## 📚 Riferimenti
- [Salesforce External ID Documentation](https://help.salesforce.com/s/articleView?id=sf.fields_about_custom_external_id.htm)
- [Salesforce REST API - Insert or Update](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_upsert.htm)
- [Salesforce Relationship Fields](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_relationship_fields.htm)
---
**Implementazione Iniziale**: 3 Febbraio 2026
**Ultimo Aggiornamento**: 17 Febbraio 2026 - ⭐ **FIX CRITICO**: Normalizzazione automatica RelationshipName
**Framework**: .NET 9.0
**Pattern**: Repository + DTO + Service Layer
**Database**: SQLite con Entity Framework Core
**UI**: Blazor Server con Bootstrap 5
+85
View File
@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace MachineGuard;
/// <summary>
/// Implementazione Windows-only della protezione machine-binding tramite DPAPI.
/// Utilizza <see cref="ProtectedData"/> con scope <see cref="DataProtectionScope.LocalMachine"/>:
/// il dato cifrato è legato fisicamente alla macchina e non può essere decifrato
/// su un'altra macchina, anche con gli stessi account o credenziali.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DpapiMachineGuard : IMachineGuard
{
private readonly MachineGuardOptions _options;
private readonly ILogger<DpapiMachineGuard> _logger;
public DpapiMachineGuard(IOptions<MachineGuardOptions> options, ILogger<DpapiMachineGuard> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc/>
public bool Verify()
{
var secretPath = ResolveSecretFilePath();
if (!File.Exists(secretPath))
{
_logger.LogError(
"MachineGuard: file secret non trovato in '{Path}'. " +
"Eseguire MachineGuardSetup.exe su questa macchina per inizializzare l'autorizzazione.",
secretPath);
return false;
}
try
{
var encryptedBytes = File.ReadAllBytes(secretPath);
var decryptedBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine);
var decryptedToken = Encoding.UTF8.GetString(decryptedBytes);
if (!string.Equals(decryptedToken, MachineGuardToken.ExpectedToken, StringComparison.Ordinal))
{
_logger.LogError(
"MachineGuard: il token decifrato non corrisponde al token atteso. " +
"Questa macchina non è autorizzata a eseguire questa applicazione.");
return false;
}
_logger.LogInformation("MachineGuard: autorizzazione macchina verificata con successo.");
return true;
}
catch (CryptographicException ex)
{
_logger.LogError(ex,
"MachineGuard: decifrazione fallita. " +
"Il file secret potrebbe provenire da un'altra macchina o essere corrotto. " +
"Percorso: '{Path}'", secretPath);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex,
"MachineGuard: errore imprevisto durante la verifica. Percorso: '{Path}'", secretPath);
return false;
}
}
private string ResolveSecretFilePath()
{
if (!string.IsNullOrWhiteSpace(_options.SecretFilePath))
return _options.SecretFilePath;
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
if (string.IsNullOrEmpty(appData))
appData = @"C:\ProgramData";
return Path.Combine(appData, "DataCoupler", "machine.guard");
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace MachineGuard;
/// <summary>
/// Interfaccia per il meccanismo di protezione machine-binding tramite DPAPI.
/// </summary>
public interface IMachineGuard
{
/// <summary>
/// Verifica che l'applicazione sia in esecuzione su una macchina autorizzata.
/// Restituisce <c>true</c> se l'autorizzazione è confermata, <c>false</c> altrimenti.
/// </summary>
bool Verify();
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
</ItemGroup>
</Project>
+75
View File
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MachineGuard;
/// <summary>
/// Metodi di estensione per la registrazione di MachineGuard nel container DI.
/// </summary>
public static class MachineGuardExtensions
{
/// <summary>
/// Registra il servizio <see cref="IMachineGuard"/> nel container DI.
/// <para>
/// Se <c>MachineGuard:Enabled</c> è <c>false</c> in appsettings, viene registrato
/// un guard no-op che approva sempre la verifica (utile per sviluppo/CI).
/// Su piattaforme non-Windows, DPAPI non è disponibile e il guard viene disabilitato automaticamente.
/// </para>
/// </summary>
public static IServiceCollection AddMachineGuard(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<MachineGuardOptions>(
configuration.GetSection(MachineGuardOptions.SectionName));
services.AddSingleton<IMachineGuard>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("MachineGuard.Startup");
#if DEBUG
// In build Debug la protezione è sempre disabilitata — nessuna configurazione richiesta.
logger.LogInformation(
"MachineGuard: build DEBUG — protezione machine-binding disabilitata automaticamente.");
return new NullMachineGuard();
#else
// In build Release la protezione è sempre attiva.
// Può essere disabilitata esplicitamente via MachineGuard:Enabled = false
// (utile per ambienti Linux/Docker o casi eccezionali).
var options = sp.GetRequiredService<IOptions<MachineGuardOptions>>();
if (!options.Value.Enabled)
{
logger.LogWarning(
"MachineGuard: protezione machine-binding DISABILITATA via configurazione. " +
"Impostare MachineGuard:Enabled = true in produzione.");
return new NullMachineGuard();
}
if (!OperatingSystem.IsWindows())
{
logger.LogWarning(
"MachineGuard: DPAPI non è disponibile su piattaforme non-Windows. " +
"La protezione machine-binding è bypassata automaticamente.");
return new NullMachineGuard();
}
return CreateWindowsGuard(sp, options);
#endif
});
return services;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static IMachineGuard CreateWindowsGuard(
IServiceProvider sp,
IOptions<MachineGuardOptions> options)
{
var logger = sp.GetRequiredService<ILogger<DpapiMachineGuard>>();
return new DpapiMachineGuard(options, logger);
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace MachineGuard;
/// <summary>
/// Opzioni di configurazione per MachineGuard.
/// Configurabili tramite appsettings.json nella sezione "MachineGuard".
/// </summary>
public sealed class MachineGuardOptions
{
/// <summary>Nome della sezione in appsettings.json.</summary>
public const string SectionName = "MachineGuard";
/// <summary>
/// Imposta a <c>false</c> per disabilitare completamente la protezione machine-binding.
/// Utile in ambienti di sviluppo o CI. Default: <c>true</c>.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Percorso del file secret cifrato.
/// Se vuoto, viene usato il percorso predefinito:
/// Windows: %ProgramData%\DataCoupler\machine.guard
/// Linux: /etc/datacoupler/machine.guard
/// </summary>
public string? SecretFilePath { get; set; }
}
+73
View File
@@ -0,0 +1,73 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace MachineGuard;
/// <summary>
/// Helper pubblico per la scrittura e la verifica del file secret di MachineGuard.
/// Usato da MachineGuardSetup e da eventuali script di deployment.
/// </summary>
public static class MachineGuardSetupHelper
{
/// <summary>
/// Cifra il token interno con DPAPI (LocalMachine scope) e lo scrive nel percorso specificato.
/// Crea la directory di destinazione se non esiste.
/// </summary>
/// <param name="secretFilePath">
/// Percorso completo del file in cui salvare il secret cifrato.
/// Usare <see cref="GetDefaultSecretFilePath"/> per il percorso di default.
/// </param>
[SupportedOSPlatform("windows")]
public static void WriteSecret(string secretFilePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(secretFilePath);
var tokenBytes = Encoding.UTF8.GetBytes(MachineGuardToken.ExpectedToken);
var encryptedBytes = ProtectedData.Protect(tokenBytes, null, DataProtectionScope.LocalMachine);
var directory = Path.GetDirectoryName(secretFilePath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllBytes(secretFilePath, encryptedBytes);
}
/// <summary>
/// Verifica che il file secret nel percorso specificato decifrabile con successo prima del deployment.
/// </summary>
[SupportedOSPlatform("windows")]
public static bool VerifySecret(string secretFilePath)
{
if (!File.Exists(secretFilePath))
return false;
try
{
var encryptedBytes = File.ReadAllBytes(secretFilePath);
var decryptedBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine);
var decryptedToken = Encoding.UTF8.GetString(decryptedBytes);
return string.Equals(decryptedToken, MachineGuardToken.ExpectedToken, StringComparison.Ordinal);
}
catch
{
return false;
}
}
/// <summary>
/// Restituisce il percorso predefinito del file secret in base al sistema operativo corrente.
/// </summary>
public static string GetDefaultSecretFilePath()
{
if (OperatingSystem.IsWindows())
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
if (string.IsNullOrEmpty(appData))
appData = @"C:\ProgramData";
return Path.Combine(appData, "DataCoupler", "machine.guard");
}
return "/etc/datacoupler/machine.guard";
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace MachineGuard;
/// <summary>
/// Contiene il token di machine-binding cablato nel codice.
/// Modificare questo valore per ogni distribuzione autorizzata,
/// quindi eseguire MachineGuardSetup per scrivere il secret su ogni macchina target.
/// </summary>
internal static class MachineGuardToken
{
/// <summary>
/// Token atteso. Deve corrispondere esattamente al valore firmato durante il setup.
/// </summary>
internal const string ExpectedToken = "DC-F47AC10B-58CC-4372-A567-0E02B2C3D479";
}
+13
View File
@@ -0,0 +1,13 @@
namespace MachineGuard;
/// <summary>
/// Implementazione no-op di <see cref="IMachineGuard"/>.
/// Usata quando la protezione è disabilitata via configurazione
/// o quando il sistema operativo non supporta DPAPI (es. Linux, macOS).
/// Restituisce sempre <c>true</c> senza eseguire alcuna verifica.
/// </summary>
internal sealed class NullMachineGuard : IMachineGuard
{
/// <inheritdoc/>
public bool Verify() => true;
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>MachineGuardSetup</AssemblyName>
<RootNamespace>MachineGuardSetup</RootNamespace>
<!-- Standalone: publish as single self-contained exe for easy deployment -->
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MachineGuard\MachineGuard.csproj" />
</ItemGroup>
</Project>
+198
View File
@@ -0,0 +1,198 @@
using MachineGuard;
// ============================================================
// MachineGuardSetup — Strumento di configurazione machine-binding
// Utilizzo: eseguire come Amministratore su ogni server autorizzato
// Indipendente da Data Coupler — nessuna dipendenza dall'applicazione
// ============================================================
Console.OutputEncoding = System.Text.Encoding.UTF8;
PrintBanner();
if (!OperatingSystem.IsWindows())
{
PrintError("Questo strumento richiede Windows (DPAPI è un'API esclusiva di Windows).");
return 1;
}
if (!IsRunningAsAdministrator())
{
PrintWarning("Attenzione: l'applicazione non è in esecuzione come Amministratore.");
PrintWarning("La scrittura in C:\\ProgramData potrebbe fallire senza privilegi elevati.");
Console.WriteLine();
}
return RunSetupWindows();
// ─────────────────────────────────────────────────────────────
// Entry point per Windows (isolato per soddisfare l'analizzatore)
// ─────────────────────────────────────────────────────────────
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
static int RunSetupWindows()
{
var defaultPath = MachineGuardSetupHelper.GetDefaultSecretFilePath();
Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
Console.WriteLine("║ CONFIGURAZIONE MACHINE-BINDING ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
Console.WriteLine();
Console.WriteLine($" Percorso predefinito secret: {defaultPath}");
Console.WriteLine();
// Chiedi conferma o percorso personalizzato
Console.Write(" Usare il percorso predefinito? [S/n]: ");
var input = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
string targetPath;
if (string.IsNullOrEmpty(input) || input == "S" || input == "Y")
{
targetPath = defaultPath;
}
else
{
Console.Write(" Inserire il percorso completo del file secret: ");
var customPath = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(customPath))
{
PrintError("Percorso non valido. Operazione annullata.");
return 1;
}
targetPath = customPath;
}
// Verifica se esiste già un secret
if (File.Exists(targetPath))
{
Console.WriteLine($" ⚠ Il file secret esiste già: {targetPath}");
Console.Write(" Sovrascrivere? [s/N]: ");
var overwrite = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
if (overwrite != "S" && overwrite != "Y")
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(" Operazione annullata dall'utente.");
Console.ResetColor();
return 0;
}
// Verifica se il secret attuale è già valido
Console.WriteLine(" Verifica del secret esistente...");
if (MachineGuardSetupHelper.VerifySecret(targetPath))
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✓ Il secret esistente è GIÀ valido per questa macchina.");
Console.ResetColor();
Console.Write(" Continuare comunque e riscrivere? [s/N]: ");
var rewrite = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
if (rewrite != "S" && rewrite != "Y")
{
Console.WriteLine(" Operazione annullata. Il secret esistente rimane invariato.");
return 0;
}
}
}
// Scrittura del secret
Console.WriteLine($" Scrittura del secret cifrato con DPAPI (LocalMachine) in:");
Console.WriteLine($" {targetPath}");
Console.WriteLine();
try
{
MachineGuardSetupHelper.WriteSecret(targetPath);
}
catch (UnauthorizedAccessException ex)
{
PrintError($"Accesso negato: {ex.Message}");
PrintError("Riprovare eseguendo il programma come Amministratore (tasto destro → Esegui come amministratore).");
return 1;
}
catch (Exception ex)
{
PrintError($"Errore durante la scrittura del secret: {ex.Message}");
return 1;
}
// Verifica post-scrittura
Console.WriteLine(" Verifica del secret appena scritto...");
if (MachineGuardSetupHelper.VerifySecret(targetPath))
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✓ Secret scritto e verificato con successo!");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine(" Questa macchina è ora autorizzata a eseguire Data Coupler.");
Console.WriteLine();
PrintFileInfo(targetPath);
}
else
{
PrintError("Verifica post-scrittura fallita. Il file potrebbe essere corrotto.");
return 1;
}
Console.WriteLine();
Console.WriteLine(" Premere un tasto per uscire...");
if (!Console.IsInputRedirected)
Console.ReadKey(intercept: true);
return 0;
}
// ─────────────────────────────────────────────────────────────
// Funzioni di utilità
// ─────────────────────────────────────────────────────────────
static void PrintBanner()
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine();
Console.WriteLine(" ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ██╗ ██╗██████╗ ██╗ ███████╗██████╗ ");
Console.WriteLine(" ██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗ ██╔════╝██╔═══██╗██║ ██║██╔══██╗██║ ██╔════╝██╔══██╗");
Console.WriteLine(" ██║ ██║███████║ ██║ ███████║ ██║ ██║ ██║██║ ██║██████╔╝██║ █████╗ ██████╔╝");
Console.WriteLine(" ██║ ██║██╔══██║ ██║ ██╔══██║ ██║ ██║ ██║██║ ██║██╔═══╝ ██║ ██╔══╝ ██╔══██╗");
Console.WriteLine(" ██████╔╝██║ ██║ ██║ ██║ ██║ ╚██████╗╚██████╔╝╚██████╔╝██║ ███████╗███████╗██║ ██║");
Console.WriteLine(" ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚══════╝╚═╝ ╚═╝");
Console.ResetColor();
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine(" MachineGuardSetup — Configurazione protezione machine-binding per Data Coupler");
Console.WriteLine(" Versione: 1.0 | Tecnologia: DPAPI (DataProtectionScope.LocalMachine)");
Console.ResetColor();
Console.WriteLine(new string('─', 70));
Console.WriteLine();
}
static void PrintError(string message)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" ✗ ERRORE: {message}");
Console.ResetColor();
}
static void PrintWarning(string message)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" ⚠ {message}");
Console.ResetColor();
}
static void PrintFileInfo(string path)
{
var fi = new FileInfo(path);
Console.WriteLine(" Dettagli file:");
Console.WriteLine($" Percorso : {fi.FullName}");
Console.WriteLine($" Dimensione: {fi.Length} byte (cifrati con DPAPI)");
Console.WriteLine($" Creato : {fi.CreationTime:dd/MM/yyyy HH:mm:ss}");
}
static bool IsRunningAsAdministrator()
{
if (!OperatingSystem.IsWindows()) return false;
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}
+164
View File
@@ -0,0 +1,164 @@
# Pubblicazione Data-Coupler: Guida 32-bit e 64-bit
## Perché è importante scegliere la piattaforma target
Alcune tecnologie di connessione sono vincolate alla piattaforma (32-bit o 64-bit):
| Tecnologia | Supporto | Note |
|---|---|---|
| **VFPOLEDB.1** (Visual FoxPro) | **Solo 32-bit** | Driver COM 32-bit, incompatibile con processi 64-bit |
| **Microsoft.ACE.OLEDB.12.0** (Access 2010) | 32-bit o 64-bit (match) | Installa la versione corrispondente all'app |
| **Microsoft.Jet.OLEDB.4.0** | **Solo 32-bit** | Driver legacy |
| ODBC generico | Dipende dal driver | Usa Gestore ODBC a 64-bit per driver 64-bit |
| SQL Server, MySQL, PostgreSQL, ecc. | 64-bit (consigliato) | Driver nativi .NET, nessun vincolo |
---
## Comandi di Pubblicazione
### 1. Pubblicazione Windows 64-bit (default, consigliato per SQL Server/MySQL/API REST)
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime win-x64 `
--self-contained true `
--output ./publish/win-x64
```
**Usa per**: SQL Server, MySQL, PostgreSQL, Oracle, REST API, ODBC 64-bit
**Non usare per**: VFPOLEDB, Jet 4.0
---
### 2. Pubblicazione Windows 32-bit (richiesta per Visual FoxPro / VFPOLEDB)
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime win-x86 `
--self-contained true `
--output ./publish/win-x86
```
**Usa per**: VFPOLEDB.1 (Visual FoxPro 8/9), Microsoft.Jet.OLEDB.4.0, driver OLE DB legacy 32-bit
**Nota**: Il processo sarà 32-bit — massima RAM ≈ 4GB.
---
### 3. Pubblicazione Linux x64
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime linux-x64 `
--self-contained true `
--output ./publish/linux-x64
```
**Attenzione**: OLE DB e ODBC (drivers Windows) **non sono supportati** su Linux.
Su Linux sono disponibili solo: SQL Server, MySQL, PostgreSQL, Oracle, SQLite, DB2, SAP HANA, REST API.
---
### 4. Pubblicazione macOS (Intel)
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime osx-x64 `
--self-contained true `
--output ./publish/osx-x64
```
---
### 5. Pubblicazione macOS (Apple Silicon - ARM64)
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime osx-arm64 `
--self-contained true `
--output ./publish/osx-arm64
```
---
## Pubblicazione Framework-Dependent (senza runtime incluso)
Se .NET 9 è già installato sul server target:
```powershell
# Windows (framework-dependent, lascia a .NET la scelta della bitness)
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--output ./publish/framework-dependent
# Forzare 32-bit anche in framework-dependent (per VFP):
# Compilare il progetto con PlatformTarget = x86 o usare --runtime win-x86
```
---
## Setup per Visual FoxPro (VFPOLEDB.1)
### Prerequisiti
1. **Driver VFPOLEDB installato** (32-bit):
- Download: [Microsoft OLE DB Provider for Visual FoxPro 9.0 SP2](https://www.microsoft.com/en-us/download/details.aspx?id=14839)
- Verifica installazione: `HKEY_CLASSES_ROOT\VFPOLEDB.1` deve esistere nel registro
2. **Applicazione pubblicata come 32-bit** (`--runtime win-x86`)
3. **Connection string esempio VFP**:
```
Provider=VFPOLEDB.1;Data Source=C:\VFP\Database\miodb.dbc;Collating Sequence=machine;
```
Per tabelle free (.dbf):
```
Provider=VFPOLEDB.1;Data Source=C:\VFP\Tabelle\;Collating Sequence=machine;
```
### Verifica Rapida in PowerShell
```powershell
# Verifica driver VFP installato
Get-Item "HKCR:\VFPOLEDB.1" -ErrorAction SilentlyContinue
# Verifica processo 32-bit in esecuzione
[System.Environment]::Is64BitProcess # deve restituire False
```
---
## Docker
Per Docker con VFP (non consigliato — driver COM Windows-only):
```dockerfile
# Nel Dockerfile, non è possibile usare VFPOLEDB su Linux container
# Usare Windows Container (opzione Dockerfile.windows)
```
Per **Windows Container** con supporto 32-bit, modifica il `Dockerfile.windows`:
```dockerfile
# Usa immagine Windows nano server
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
# Copia publish win-x86
COPY ./publish/win-x86 /app
```
---
## Riepilogo Rapido
| Scenario | Comando |
|---|---|
| Solo database SQL + REST API | `--runtime win-x64` |
| Visual FoxPro / OLE DB legacy | `--runtime win-x86` |
| Server Linux | `--runtime linux-x64` |
| macOS Intel | `--runtime osx-x64` |
| macOS Apple Silicon | `--runtime osx-arm64` |
+5
View File
@@ -8,6 +8,11 @@ Data-Coupler è una soluzione integrata per la gestione di connessioni dati e cr
- **DataConnection**: Libreria per connessioni a database e API REST - **DataConnection**: Libreria per connessioni a database e API REST
- **Data_Coupler**: Applicazione Blazor Server per l'interfaccia utente - **Data_Coupler**: Applicazione Blazor Server per l'interfaccia utente
### 🆕 Novità Recenti (Febbraio 2026)
-**Salesforce Batch Describe via Composite API**: I metadati degli SObject vengono ora recuperati in batch (25 per chiamata) invece di N chiamate singole, riducendo drasticamente il consumo di API durante la discovery
-**Discovery REST Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` vengono eseguite in parallelo; la lista entità diventa interattiva quasi subito, i dettagli arrivano in background
-**Fix Scheduler External ID Relationships**: Corretti due bug nello schedulatore — `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano azzerati al re-salvataggio del profilo; i campi sorgente usati nelle relazioni External ID non venivano esclusi dal mapping normale
### 🆕 Novità Recenti (Gennaio 2026) ### 🆕 Novità Recenti (Gennaio 2026)
-**Schedulazione File CSV/Excel**: Supporto completo per schedulare trasferimenti da file -**Schedulazione File CSV/Excel**: Supporto completo per schedulare trasferimenti da file
-**Validazione Percorsi**: Validazione file prima del salvataggio profili -**Validazione Percorsi**: Validazione file prima del salvataggio profili
+345
View File
@@ -0,0 +1,345 @@
# Fix Connessione SQL Server con Localhost
**Data**: 15 Febbraio 2026
**Versione**: 2.1+
## 📋 Problema Risolto
Il sistema non riusciva a connettersi correttamente a SQL Server quando si utilizzava "localhost" come host, specialmente per:
- Named Instances (es. `localhost\SQLEXPRESS`)
- LocalDB (es. `(localdb)\MSSQLLocalDB`)
- Windows Authentication
## 🔧 Modifiche Implementate
### 1. ConnectionStringBuilder - Gestione Intelligente del Server
**File**: `CredentialManager/Models/CredentialModels.cs`
#### Miglioramenti:
**a) Named Instances**
- Se l'host contiene `\` (backslash), la porta viene omessa automaticamente
- Esempi supportati:
- `localhost\SQLEXPRESS`
- `.\SQLEXPRESS`
- `SERVERNAME\INSTANCE`
**b) LocalDB**
- Se l'host inizia con `(localdb)`, la porta viene omessa
- Esempi supportati:
- `(localdb)\MSSQLLocalDB`
- `(localdb)\v11.0`
- `(localdb)\ProjectsV13`
**c) Localhost con Named Pipes**
- Per `localhost`, `.` o `127.0.0.1` con porta 1433 (default), la porta viene omessa
- Questo permette a SQL Server di usare Named Pipes invece di TCP/IP per connessioni locali più veloci
**d) Windows Authentication**
- Se username è vuoto, `Integrated` o `Windows`, usa Windows Authentication
- Non richiede password quando si usa Windows Authentication
- Connection string include `Integrated Security=True`
#### Codice Modificato:
```csharp
private static string BuildSqlServerConnectionString(DatabaseCredential credential)
{
var builder = new List<string>();
// Gestione speciale per SQL Server locale e named instances
bool hasInstanceName = credential.Host.Contains('\\') ||
credential.Host.StartsWith("(localdb)", StringComparison.OrdinalIgnoreCase);
if (hasInstanceName)
{
// Per named instances e LocalDB, non includere la porta
builder.Add($"Server={credential.Host}");
}
else
{
// Per localhost con porta default, ometti la porta per usare Named Pipes
if ((credential.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
credential.Host == "." ||
credential.Host == "127.0.0.1") && credential.Port == 1433)
{
builder.Add($"Server={credential.Host}");
}
else
{
// Per altri casi, usa host,porta
builder.Add($"Server={credential.Host},{credential.Port}");
}
}
// Windows Authentication vs SQL Authentication
if (string.IsNullOrWhiteSpace(credential.Username) ||
credential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) ||
credential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase))
{
builder.Add("Integrated Security=True");
}
else
{
builder.Add($"User Id={credential.Username}");
builder.Add($"Password={credential.Password}");
}
builder.Add($"Connection Timeout={credential.CommandTimeout}");
if (!string.IsNullOrEmpty(credential.DatabaseName))
builder.Add($"Database={credential.DatabaseName}");
if (credential.IgnoreSslErrors)
builder.Add("TrustServerCertificate=True");
return string.Join(";", builder);
}
```
### 2. UI - Guida Contestuale per SQL Server
**File**: `Data_Coupler/Pages/CredentialManagement.razor`
#### Aggiunte:
**a) Help Text per Host/Server**
- Mostra esempi specifici per SQL Server locale:
- Named Instance: `localhost\SQLEXPRESS` o `.\SQLEXPRESS`
- LocalDB: `(localdb)\MSSQLLocalDB`
- Default: `localhost` o `.` (usa porta 1433)
**b) Nota sulla Porta**
- Indica che la porta viene ignorata per named instances e LocalDB
**c) Guida Windows Authentication**
- Nel campo Username: placeholder "o scrivi 'Integrated' per Windows Auth"
- Help text: "Per Windows Authentication, scrivi **Integrated** o lascia vuoto"
- Nel campo Password: "Non richiesta per Windows Authentication"
#### Codice Aggiunto:
```razor
@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer)
{
<div class="form-text">
<strong>SQL Server locale:</strong><br/>
• Named Instance: <code>localhost\SQLEXPRESS</code> o <code>.\SQLEXPRESS</code><br/>
• LocalDB: <code>(localdb)\MSSQLLocalDB</code><br/>
• Default: <code>localhost</code> o <code>.</code> (usa porta 1433)
</div>
}
```
### 3. Validazione Aggiornata
**File**: `Data_Coupler/Pages/CredentialManagement.razor`
#### Miglioramenti:
**a) Validazione Credenziali**
- Permette username/password vuoti per SQL Server con Windows Authentication
- Riconosce "Integrated" e "Windows" come segnali per Windows Authentication
- Validazione più specifica con messaggi di errore appropriati
#### Codice Modificato:
```csharp
// Per SQL Server, permetti Windows Authentication
bool isSqlServerWithWindowsAuth = currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer &&
(string.IsNullOrWhiteSpace(currentDatabaseCredential.Username) ||
currentDatabaseCredential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) ||
currentDatabaseCredential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase));
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;
}
}
```
## 📚 Guida Utilizzo
### Scenario 1: SQL Server Express Locale
**Configurazione Credenziale:**
- **Host**: `localhost\SQLEXPRESS` o `.\SQLEXPRESS`
- **Porta**: 1433 (ignorata)
- **Database**: Nome del database (es. `MyDatabase`)
- **Username**: `Integrated` o lascia vuoto
- **Password**: Lascia vuoto
**Connection String Generata:**
```
Server=localhost\SQLEXPRESS;Integrated Security=True;Connection Timeout=30;Database=MyDatabase;TrustServerCertificate=True
```
### Scenario 2: SQL Server LocalDB
**Configurazione Credenziale:**
- **Host**: `(localdb)\MSSQLLocalDB`
- **Porta**: 1433 (ignorata)
- **Database**: Nome del database (es. `TestDB`)
- **Username**: `Integrated` o lascia vuoto
- **Password**: Lascia vuoto
**Connection String Generata:**
```
Server=(localdb)\MSSQLLocalDB;Integrated Security=True;Connection Timeout=30;Database=TestDB
```
### Scenario 3: SQL Server Locale con SQL Authentication
**Configurazione Credenziale:**
- **Host**: `localhost`
- **Porta**: 1433
- **Database**: Nome del database (es. `Production`)
- **Username**: `sa` (o un altro utente SQL)
- **Password**: Password dell'utente
**Connection String Generata:**
```
Server=localhost;User Id=sa;Password=***;Connection Timeout=30;Database=Production;TrustServerCertificate=True
```
### Scenario 4: SQL Server Remoto
**Configurazione Credenziale:**
- **Host**: `sql.example.com`
- **Porta**: 1433 (o porta custom, es. 14330)
- **Database**: Nome del database
- **Username**: Utente SQL
- **Password**: Password
**Connection String Generata:**
```
Server=sql.example.com,1433;User Id=username;Password=***;Connection Timeout=30;Database=DBName;TrustServerCertificate=True
```
### Scenario 5: SQL Server con Instance Name Remoto
**Configurazione Credenziale:**
- **Host**: `server.domain.com\PRODUCTION`
- **Porta**: 1433 (ignorata)
- **Database**: Nome del database
- **Username**: Utente SQL
- **Password**: Password
**Connection String Generata:**
```
Server=server.domain.com\PRODUCTION;User Id=username;Password=***;Connection Timeout=30;Database=DBName;TrustServerCertificate=True
```
## 🔍 Troubleshooting
### Problema: "A network-related or instance-specific error"
**Possibili Cause:**
1. **SQL Server Browser non in esecuzione** (per named instances)
- Soluzione: Avvia il servizio "SQL Server Browser" da services.msc
2. **TCP/IP non abilitato**
- Soluzione: SQL Server Configuration Manager → Protocols → Enable TCP/IP
3. **Named Instance non specificata**
- Soluzione: Usa `localhost\SQLEXPRESS` invece di solo `localhost`
4. **Firewall blocca la porta**
- Soluzione: Aggiungi eccezione firewall per SQL Server
### Problema: "Login failed for user"
**Possibili Cause:**
1. **Windows Authentication richiesta ma SQL Auth specificata**
- Soluzione: Usa username `Integrated` o lascialo vuoto
2. **SQL Authentication non abilitata**
- Soluzione: SQL Server Management Studio → Proprietà Server → Security → SQL Server and Windows Authentication mode
3. **Password errata**
- Soluzione: Verifica la password
### Problema: "Cannot open database"
**Possibili Cause:**
1. **Database non esiste**
- Soluzione: Verifica il nome del database o lascia il campo vuoto per connetterti solo al server
2. **Permessi insufficienti**
- Soluzione: Verifica che l'utente abbia accesso al database
## ✅ Test di Connessione
Dopo aver configurato la credenziale, usa il pulsante **"Testa Connessione"** per verificare:
- ✅ Connection string corretta
- ✅ SQL Server raggiungibile
- ✅ Autenticazione riuscita
- ✅ Database accessibile (se specificato)
Il test mostra:
- Versione SQL Server
- Host e porta usati
- Database connesso
- Timeout configurato
## 📝 Note Tecniche
### Differenze TCP/IP vs Named Pipes
**Named Pipes** (preferito per localhost):
- Più veloce per connessioni locali
- Non richiede SQL Server Browser
- Usa IPC invece di network stack
- Sintassi: `Server=localhost` o `Server=.`
**TCP/IP** (richiesto per remote):
- Richiesto per connessioni remote
- Richiede porta specifica
- Richiede SQL Server Browser per named instances
- Sintassi: `Server=hostname,port`
### Windows Authentication vs SQL Authentication
**Windows Authentication**:
- ✅ Più sicuro (usa credenziali Windows)
- ✅ No password nel codice
- ✅ Single Sign-On
- ❌ Richiede domain trust per remote
**SQL Authentication**:
- ✅ Funziona sempre (anche cross-domain)
- ✅ Credenziali specifiche per SQL Server
- ❌ Password nel connection string
- ❌ Deve essere abilitato in SQL Server
## 🔄 Retrocompatibilità
Le modifiche sono completamente retrocompatibili:
- ✅ Connection string esistenti continuano a funzionare
- ✅ Credenziali già salvate non richiedono modifiche
- ✅ Comportamento default invariato per server remoti
- ✅ Nessuna migrazione database richiesta
## 📊 Impatto Performance
**Miglioramenti**:
- 🚀 Named Pipes più veloce di TCP/IP per localhost
- 🚀 Riduzione overhead network stack
- 🚀 Connection pooling più efficiente
**Nessun Impatto Negativo**:
- ✅ Server remoti usano sempre TCP/IP (comportamento corretto)
- ✅ Connection string ottimizzate per scenario specifico
---
**Sviluppatore**: Alessio Dalsanto
**Issue**: Connessione localhost SQL Server
**Status**: ✅ Risolto