16 Commits

Author SHA1 Message Date
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
32 changed files with 3835 additions and 214 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.
+26 -2
View File
@@ -13,6 +13,30 @@
- **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 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 +1175,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");
+44 -6
View File
@@ -174,13 +174,51 @@ public static class ConnectionStringBuilder
}; };
} 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))
@@ -59,6 +59,15 @@ public class DataCouplerProfile
// Mapping dei campi salvato come JSON // Mapping dei campi salvato come JSON
[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)]
@@ -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;
}
}
}
@@ -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;
@@ -200,6 +202,100 @@ public class DataCouplerProfileService : IDataCouplerProfileService
return new List<FieldMappingDto>(); return new List<FieldMappingDto>();
} }
} }
/// <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
@@ -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
+1
View File
@@ -15,6 +15,7 @@
<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" />
</ItemGroup> </ItemGroup>
@@ -175,70 +175,43 @@ namespace DataConnection.REST.Implementations
var entities = new List<RestEntityInfo>(); var entities = new List<RestEntityInfo>();
try try
{ {
// First, get list of all SObjects // Step 1: get list of all SObjects (1 API call)
var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/"; var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/";
var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken); var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var sobjectsResponse = await response.Content.ReadFromJsonAsync<SalesforceSObjectsResponse>(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null) var sobjectsResponse = await response.Content.ReadFromJsonAsync<SalesforceSObjectsResponse>(cancellationToken: cancellationToken);
if (sobjectsResponse?.SObjects != null)
{ {
// For demo purposes, limit to first 20 objects to avoid too many API calls var sObjectNames = sobjectsResponse.SObjects
var limitedSObjects = sobjectsResponse.SObjects.ToList(); .Where(s => !string.IsNullOrEmpty(s.Name))
.Select(s => s.Name!)
.ToList();
// Process SObjects in parallel for better performance Console.WriteLine($"DiscoverEntities: {sObjectNames.Count} SObjects. Using Composite Batch API ({Math.Ceiling((double)sObjectNames.Count / 25)} request(s) instead of {sObjectNames.Count}).");
var semaphore = new SemaphoreSlim(20, 20); // Limit concurrent requests to 5
var tasks = limitedSObjects.Where(sobject => !string.IsNullOrEmpty(sobject.Name)) // Step 2: batch describe all SObjects via Composite Batch API (25 per request)
.Select(async sobject => var describeResults = await BatchDescribeSObjectsAsync(sObjectNames, cancellationToken);
foreach (var sobject in sobjectsResponse.SObjects)
{
if (string.IsNullOrEmpty(sobject.Name)) continue;
if (!describeResults.TryGetValue(sobject.Name, out var describeResult) || describeResult?.Fields == null)
continue;
var entityInfo = new RestEntityInfo { Name = sobject.Name };
foreach (var field in describeResult.Fields)
{ {
await semaphore.WaitAsync(cancellationToken); if (string.IsNullOrEmpty(field.Name)) continue;
try entityInfo.Properties.Add(new RestPropertyInfo
{ {
// Get detailed field information for each SObject Name = field.Name,
var describeEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{sobject.Name}/describe/"; Type = field.Type ?? "string",
var describeResponse = await _httpClient.GetAsync(describeEndpoint, cancellationToken); IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)
});
if (describeResponse.IsSuccessStatusCode) }
{ entities.Add(entityInfo);
var describeResult = await describeResponse.Content.ReadFromJsonAsync<SalesforceDescribeResponse>(cancellationToken: cancellationToken); }
if (describeResult?.Fields != null)
{
var entityInfo = new RestEntityInfo
{
Name = sobject.Name
};
foreach (var field in describeResult.Fields)
{
if (string.IsNullOrEmpty(field.Name)) continue;
var propInfo = new RestPropertyInfo
{
Name = field.Name,
Type = field.Type ?? "string",
IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)
};
entityInfo.Properties.Add(propInfo);
}
return entityInfo;
}
}
return null;
}
catch (Exception ex)
{
Console.WriteLine($"Error describing SObject {sobject.Name}: {ex.Message}");
return null;
}
finally
{
semaphore.Release();
}
});
var results = await Task.WhenAll(tasks);
entities.AddRange(results.Where(result => result != null)!);
} }
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
@@ -382,6 +355,116 @@ namespace DataConnection.REST.Implementations
return null; return null;
} }
/// <summary>
/// Describes multiple SObjects in batches using the Salesforce Composite Batch API.
/// Reduces API calls from N (one per object) to ceil(N/25) by grouping up to 25 describe
/// requests per Composite Batch call.
/// </summary>
private async Task<Dictionary<string, SalesforceDescribeResponse?>> BatchDescribeSObjectsAsync(
List<string> sObjectNames, CancellationToken cancellationToken)
{
const int maxBatchSize = 25;
var allResults = new Dictionary<string, SalesforceDescribeResponse?>(StringComparer.OrdinalIgnoreCase);
// Split into batches of 25 (Salesforce Composite Batch limit)
var batches = new List<(List<string> Names, int BatchNumber)>();
for (int i = 0; i < sObjectNames.Count; i += maxBatchSize)
{
var chunk = sObjectNames.Skip(i).Take(maxBatchSize).ToList();
batches.Add((chunk, (i / maxBatchSize) + 1));
}
Console.WriteLine($"BatchDescribeSObjects: {sObjectNames.Count} objects → {batches.Count} Composite Batch request(s)");
var batchEndpoint = $"{_instanceUrl}/services/data/v60.0/composite/batch";
// Execute all batches in parallel
var batchTasks = batches.Select(async b =>
{
Console.WriteLine($"BatchDescribeSObjects: sending batch {b.BatchNumber}/{batches.Count} ({b.Names.Count} objects)");
var batchRequest = new SalesforceBatchDescribeRequest
{
BatchRequests = b.Names.Select(name => new SalesforceBatchDescribeSubRequest
{
Method = "GET",
Url = $"/services/data/v60.0/sobjects/{name}/describe/"
}).ToList()
};
var jsonContent = new StringContent(
JsonSerializer.Serialize(batchRequest, SalesforceJsonOptions),
System.Text.Encoding.UTF8,
"application/json"
);
var batchResults = new Dictionary<string, SalesforceDescribeResponse?>(StringComparer.OrdinalIgnoreCase);
try
{
var response = await _httpClient.PostAsync(batchEndpoint, jsonContent, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var err = await response.Content.ReadAsStringAsync(cancellationToken);
Console.WriteLine($"BatchDescribeSObjects batch {b.BatchNumber} failed: {response.StatusCode} - {err}");
foreach (var name in b.Names) batchResults[name] = null;
return batchResults;
}
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
var batchResponse = JsonSerializer.Deserialize<SalesforceBatchDescribeResponse>(responseContent, SalesforceJsonOptions);
if (batchResponse?.Results != null)
{
for (int i = 0; i < b.Names.Count; i++)
{
var objectName = b.Names[i];
if (i >= batchResponse.Results.Count)
{
batchResults[objectName] = null;
continue;
}
var subResponse = batchResponse.Results[i];
if (subResponse.StatusCode >= 200 && subResponse.StatusCode < 300 && subResponse.Result.HasValue)
{
try
{
batchResults[objectName] = JsonSerializer.Deserialize<SalesforceDescribeResponse>(
subResponse.Result.Value.GetRawText(), SalesforceJsonOptions);
}
catch (JsonException ex)
{
Console.WriteLine($"BatchDescribeSObjects: failed to parse describe for {objectName}: {ex.Message}");
batchResults[objectName] = null;
}
}
else
{
Console.WriteLine($"BatchDescribeSObjects: describe for {objectName} returned status {subResponse.StatusCode}");
batchResults[objectName] = null;
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"BatchDescribeSObjects: exception in batch {b.BatchNumber}: {ex.Message}");
foreach (var name in b.Names) batchResults[name] = null;
}
return batchResults;
});
var allBatchResults = await Task.WhenAll(batchTasks);
foreach (var batchResult in allBatchResults)
foreach (var kvp in batchResult)
allResults[kvp.Key] = kvp.Value;
var successCount = allResults.Values.Count(v => v != null);
Console.WriteLine($"BatchDescribeSObjects completed: {successCount}/{sObjectNames.Count} objects described successfully.");
return allResults;
}
/// <summary> /// <summary>
/// Creates a new SObject in Salesforce. /// Creates a new SObject in Salesforce.
/// </summary> /// </summary>
@@ -1631,6 +1714,43 @@ namespace DataConnection.REST.Implementations
public string? NextRecordsUrl { get; set; } public string? NextRecordsUrl { get; set; }
} }
// ===== Composite Batch API models (for parallel describe calls) =====
private class SalesforceBatchDescribeRequest
{
[JsonPropertyName("batchRequests")]
public List<SalesforceBatchDescribeSubRequest> BatchRequests { get; set; } = new();
}
private class SalesforceBatchDescribeSubRequest
{
[JsonPropertyName("method")]
public string Method { get; set; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
}
private class SalesforceBatchDescribeResponse
{
[JsonPropertyName("hasErrors")]
public bool HasErrors { get; set; }
[JsonPropertyName("results")]
public List<SalesforceBatchDescribeSubResponse> Results { get; set; } = new();
}
private class SalesforceBatchDescribeSubResponse
{
[JsonPropertyName("statusCode")]
public int StatusCode { get; set; }
[JsonPropertyName("result")]
public JsonElement? Result { get; set; }
}
// ===== Composite API models (for create/update/query operations) =====
private class SalesforceCompositeRequest private class SalesforceCompositeRequest
{ {
[JsonPropertyName("compositeRequest")] [JsonPropertyName("compositeRequest")]
+52
View File
@@ -7,6 +7,13 @@
<!-- 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>
@@ -35,4 +42,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>
@@ -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)
{ {
+1 -1
View File
@@ -11,7 +11,7 @@
@code { @code {
private int currentCount = 0; private int currentCount = 0;
private void IncrementCount() private void IncrementCount()
{ {
currentCount++; currentCount++;
} }
+48 -5
View File
@@ -474,12 +474,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 +510,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>
@@ -994,13 +1022,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);
+291 -56
View File
@@ -920,23 +920,80 @@
<!-- Colonna Centrale: Controlli Mapping --> <!-- Colonna Centrale: Controlli Mapping -->
<div class="col-2 text-center"> <div class="col-2 text-center">
<div class="d-flex flex-column justify-content-center h-100"> <div class="d-flex flex-column justify-content-center h-100">
<button class="btn btn-success mb-2" @onclick="CreateMapping" <!-- Toggle tra Mapping e Default Value -->
disabled="@(string.IsNullOrEmpty(selectedDbColumn) || string.IsNullOrEmpty(selectedRestProperty))"> <div class="btn-group mb-3" role="group">
<i class="fas fa-arrow-right"></i> <button type="button"
<small class="d-block">Map</small> class="btn btn-sm @(isAddingDefaultValue ? "btn-outline-primary" : "btn-primary")"
</button> @onclick="@(() => isAddingDefaultValue = false)">
<button class="btn btn-danger mb-2" @onclick="RemoveMapping" <i class="fas fa-arrows-alt-h"></i>
disabled="@(string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn))"> <small class="d-block">Mapping</small>
<i class="fas fa-times"></i> </button>
<small class="d-block">Remove</small> <button type="button"
</button> class="btn btn-sm @(isAddingDefaultValue ? "btn-warning" : "btn-outline-warning")"
<button class="btn btn-warning mb-2" @onclick="AutoMapFields"> @onclick="@(() => isAddingDefaultValue = true)">
<i class="fas fa-magic"></i> <i class="fas fa-file-alt"></i>
<small class="d-block">Auto</small> <small class="d-block">Default</small>
</button> </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"> <button class="btn btn-secondary" @onclick="ClearAllMappings">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
<small class="d-block">Clear</small> <small class="d-block">Clear All</small>
</button> </button>
</div> </div>
</div> </div>
@@ -965,6 +1022,10 @@
{ {
<span class="badge bg-success">Mapped</span> <span class="badge bg-success">Mapped</span>
} }
@if (defaultValues.ContainsKey(property.Name))
{
<span class="badge bg-warning text-dark">Default</span>
}
</div> </div>
</div> </div>
</a> </a>
@@ -974,11 +1035,124 @@
</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 +1160,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>
} }
@@ -1153,6 +1384,8 @@
</div> </div>
</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">
@@ -1198,7 +1431,9 @@
DestinationCredentialId="@(GetCurrentDestinationCredentialIdAsync().Result)" DestinationCredentialId="@(GetCurrentDestinationCredentialIdAsync().Result)"
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" />
+440 -11
View File
@@ -51,9 +51,24 @@ 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
@@ -338,11 +353,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);
@@ -363,6 +380,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))
@@ -374,6 +427,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;
@@ -466,6 +564,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 +599,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 +789,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 = "";
} }
@@ -1293,6 +1398,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 +1416,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 +1530,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()
@@ -1943,11 +2312,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 +2346,61 @@ public partial class DataCoupler : ComponentBase
} }
} }
Logger.LogDebug("Record trasformato: {DbColumns} → {RestProperties}", // STEP 2: Applica i valori di default per i campi NON ancora popolati
foreach (var defaultValue in defaultValues)
{
string destinationField = defaultValue.Key;
var (value, valueType) = defaultValue.Value;
// Applica il default value solo se il campo non è già stato popolato dal mapping
if (!restData.ContainsKey(destinationField))
{
if (value != null)
{
restData[destinationField] = value;
Logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})",
destinationField, value, valueType);
}
}
else
{
Logger.LogDebug("Campo {Field} già popolato da mapping, default value ignorato", destinationField);
}
}
// STEP 3: Aggiungi External ID Relationships (per Salesforce)
if (externalIdRelationships.Any())
{
foreach (var relationship in externalIdRelationships)
{
if (!string.IsNullOrWhiteSpace(relationship.SourceField) &&
dbRecord.ContainsKey(relationship.SourceField))
{
var sourceValue = dbRecord[relationship.SourceField];
var transformedValue = TransformValue(sourceValue, relationship.SourceField, relationship.ExternalIdField);
if (transformedValue != null)
{
// Crea il dizionario annidato per l'External ID Relationship
// Formato: { "Account": { "CardCode__c": "V50000" } }
var externalIdObject = new Dictionary<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;
} }
@@ -2477,13 +2913,6 @@ public partial class DataCoupler : ComponentBase
} }
} }
// Verifica che non contenga commenti SQL potenzialmente pericolosi
if (upperQuery.Contains("--") || upperQuery.Contains("/*"))
{
Logger.LogWarning("Query rifiutata: contiene commenti SQL non consentiti");
return false;
}
return true; return true;
} }
@@ -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
@@ -794,8 +906,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);
@@ -1085,12 +1197,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 +1238,50 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
} }
} }
// 2. Applica default values (solo se il campo non è già stato mappato)
foreach (var defaultValue in defaultValues)
{
if (!restData.ContainsKey(defaultValue.Key))
{
var (value, type) = defaultValue.Value;
if (value != null)
{
restData[defaultValue.Key] = value;
_logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})",
defaultValue.Key, value, type);
}
}
}
// 3. Aggiungi External ID Relationships (per Salesforce)
if (externalIdRelationships != null && externalIdRelationships.Any())
{
foreach (var relationship in externalIdRelationships)
{
if (!string.IsNullOrWhiteSpace(relationship.SourceField) &&
sourceRecord.ContainsKey(relationship.SourceField))
{
var sourceValue = sourceRecord[relationship.SourceField];
var transformedValue = TransformValueForRest(sourceValue);
if (transformedValue != null)
{
// Crea il dizionario annidato per l'External ID Relationship
// Formato: { "Account__r": { "Country__c": "US" } }
var externalIdObject = new Dictionary<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
+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