feat: Implementa gestione intelligente della chiave sorgente con rilevamento PK

- Aggiunge rilevamento automatico Primary Key per connessioni database
- Rimuove completamente il fallback automatico per lato sorgente
- Implementa selezione manuale obbligatoria per file e sorgenti non-DB
- Migliora UI con suggerimenti intelligenti e feedback visivo
- Aggiunge validazione multi-livello (UI, pre-transfer, runtime)
- Introduce metodo GetPrimaryKeyFieldAsync in IDatabaseManager
- Modifica GenerateSourceKey per richiedere sempre campo specifico
- Implementa controllo IsTransferButtonEnabled per validazione form

Breaking changes:
- La generazione automatica delle chiavi sorgente è stata rimossa
- Il campo chiave sorgente è ora obbligatorio quando si usa il sistema associazioni

Fixes: Risolve problema di discovery schema vuoto con selezione database
This commit is contained in:
2025-06-28 02:05:59 +02:00
parent 207d6fc845
commit 51c61eabf7
29 changed files with 2748 additions and 104 deletions
@@ -56,4 +56,14 @@ public interface IDataConnectionCredentialService
Task<(bool Success, string Message)> TestSapB1ConnectionAsync(SapB1ServiceLayerCredential credential);
Task<(bool Success, string Message)> TestSalesforceConnectionAsync(string credentialName);
Task<(bool Success, string Message)> TestSalesforceConnectionAsync(SalesforceCredential credential);
// Record associations
Task<int> SaveRecordAssociationAsync(RecordAssociation association);
Task<RecordAssociation?> FindRecordAssociationAsync(string sourceName, string sourceKey, string destinationEntity);
Task<List<RecordAssociation>> GetRecordAssociationsBySourceAsync(string sourceName, string sourceType);
Task<List<RecordAssociation>> GetRecordAssociationsByDestinationAsync(string destinationEntity, string restCredentialName);
Task<List<RecordAssociation>> GetAllActiveRecordAssociationsAsync();
Task<bool> UpdateRecordAssociationAsync(RecordAssociation association);
Task<bool> DeactivateRecordAssociationAsync(int id);
Task<bool> DeleteRecordAssociationAsync(int id);
}
@@ -38,6 +38,9 @@ public static class ServiceCollectionExtensions
// Aggiungi i servizi base di CredentialManager
services.AddCredentialManager(databasePath);
// Aggiungi il servizio di gestione associazioni record
services.AddScoped<IRecordAssociationService, RecordAssociationService>();
// Aggiungi il servizio di integrazione DataConnection
services.AddScoped<IDataConnectionCredentialService, DataConnectionCredentialService>();
@@ -15,13 +15,16 @@ namespace DataConnection.CredentialManagement.Services;
public class DataConnectionCredentialService : IDataConnectionCredentialService
{
private readonly ICredentialService _credentialService;
private readonly IRecordAssociationService _recordAssociationService;
private readonly ILogger<DataConnectionCredentialService> _logger;
public DataConnectionCredentialService(
ICredentialService credentialService,
IRecordAssociationService recordAssociationService,
ILogger<DataConnectionCredentialService> logger)
{
_credentialService = credentialService;
_recordAssociationService = recordAssociationService;
_logger = logger;
}
@@ -855,4 +858,48 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
}
#endregion
#region Record Associations
public async Task<int> SaveRecordAssociationAsync(RecordAssociation association)
{
return await _recordAssociationService.SaveAssociationAsync(association);
}
public async Task<RecordAssociation?> FindRecordAssociationAsync(string sourceName, string sourceKey, string destinationEntity)
{
return await _recordAssociationService.FindAssociationAsync(sourceName, sourceKey, destinationEntity);
}
public async Task<List<RecordAssociation>> GetRecordAssociationsBySourceAsync(string sourceName, string sourceType)
{
return await _recordAssociationService.GetAssociationsBySourceAsync(sourceName, sourceType);
}
public async Task<List<RecordAssociation>> GetRecordAssociationsByDestinationAsync(string destinationEntity, string restCredentialName)
{
return await _recordAssociationService.GetAssociationsByDestinationAsync(destinationEntity, restCredentialName);
}
public async Task<List<RecordAssociation>> GetAllActiveRecordAssociationsAsync()
{
return await _recordAssociationService.GetAllActiveAssociationsAsync();
}
public async Task<bool> UpdateRecordAssociationAsync(RecordAssociation association)
{
return await _recordAssociationService.UpdateAssociationAsync(association);
}
public async Task<bool> DeactivateRecordAssociationAsync(int id)
{
return await _recordAssociationService.DeactivateAssociationAsync(id);
}
public async Task<bool> DeleteRecordAssociationAsync(int id)
{
return await _recordAssociationService.DeleteAssociationAsync(id);
}
#endregion
}
+172 -20
View File
@@ -109,35 +109,26 @@ public class EFCoreDatabaseManager : IDatabaseManager
{
try
{
Console.WriteLine($"[DEBUG] Iniziando GetDatabaseSchemaAsync - DatabaseType: {_options.DatabaseType}");
// Assicurarsi che il contesto sia connesso
await _context.Database.OpenConnectionAsync();
Console.WriteLine($"[DEBUG] Connessione al database aperta. Connection string: {_context.Database.GetConnectionString()}");
// Usa la factory per ottenere il provider appropriato in base al tipo di database
var schemaProvider = DatabaseSchemaProviderFactory.CreateProvider(_options.DatabaseType);
Console.WriteLine($"[DEBUG] Schema provider creato: {schemaProvider.GetType().Name}");
// Usa il provider per ottenere lo schema
var result = await schemaProvider.GetDatabaseSchemaAsync(_context.Database.GetConnectionString());
Console.WriteLine($"[DEBUG] Schema ottenuto. Numero tabelle: {result?.Count ?? 0}");
if (result != null && result.Count > 0)
{
foreach (var table in result.Take(3))
{
Console.WriteLine($"[DEBUG] Tabella: {table.Key}, Colonne: {table.Value?.Count() ?? 0}");
}
}
var connectionString = _context.Database.GetConnectionString();
if (connectionString == null)
throw new InvalidOperationException("Connection string is null");
var result = await schemaProvider.GetDatabaseSchemaAsync(connectionString);
return result;
}
catch (Exception ex)
{
Console.WriteLine($"Errore nel recupero dello schema del database: {ex.Message}");
Console.WriteLine($"[DEBUG] Stack trace: {ex.StackTrace}");
throw; }
throw;
}
} public async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsAsync(string tableName)
{
try
@@ -146,7 +137,8 @@ public class EFCoreDatabaseManager : IDatabaseManager
// Usa la stessa connection string utilizzata per il discovery dello schema
var connectionString = _context.Database.GetConnectionString();
Console.WriteLine($"[DEBUG] GetAllRecordsAsync - Using connection string: {connectionString?.Substring(0, Math.Min(50, connectionString?.Length ?? 0))}...");
if (connectionString == null)
throw new InvalidOperationException("Connection string is null");
// Determina il tipo di connessione in base al DatabaseType
using var connection = CreateConnection(connectionString);
@@ -171,7 +163,6 @@ public class EFCoreDatabaseManager : IDatabaseManager
}
command.CommandText = $"SELECT TOP 1000 * FROM {tableReference}";
Console.WriteLine($"[DEBUG] GetAllRecordsAsync - Query: {command.CommandText}");
using var reader = await command.ExecuteReaderAsync();
@@ -183,13 +174,12 @@ public class EFCoreDatabaseManager : IDatabaseManager
{
var columnName = reader.GetName(i);
var value = reader.IsDBNull(i) ? null : reader.GetValue(i);
record[columnName] = value;
record[columnName] = value!;
}
records.Add(record);
}
Console.WriteLine($"[DEBUG] GetAllRecordsAsync - Tabella: {tableName}, Record ottenuti: {records.Count}");
return records;
}
catch (Exception ex)
@@ -199,6 +189,114 @@ public class EFCoreDatabaseManager : IDatabaseManager
}
}
public async Task<List<string>> GetAvailableDatabasesAsync()
{
try
{
var connectionString = _context.Database.GetConnectionString();
if (connectionString == null)
throw new InvalidOperationException("Connection string is null");
// Crea una connessione al server (senza specificare il database)
var serverConnectionString = GetServerConnectionString(connectionString);
using var connection = CreateConnection(serverConnectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
// Query per ottenere i database disponibili (esclude quelli di sistema)
command.CommandText = @"
SELECT name
FROM sys.databases
WHERE state_desc = 'ONLINE'
AND name NOT IN ('master', 'tempdb', 'model', 'msdb', 'distribution')
ORDER BY name";
var databases = new List<string>();
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
databases.Add(reader.GetString(0));
}
return databases;
}
catch (Exception ex)
{
Console.WriteLine($"Errore nell'ottenere la lista dei database: {ex.Message}");
throw;
}
}
public async Task ChangeDatabaseAsync(string databaseName)
{
try
{
var currentConnectionString = _context.Database.GetConnectionString();
if (currentConnectionString == null)
throw new InvalidOperationException("Connection string is null");
// Crea una nuova connection string con il database specificato
var newConnectionString = UpdateConnectionStringDatabase(currentConnectionString, databaseName);
// Ricrea il contesto con la nuova connection string
var optionsBuilder = new DbContextOptionsBuilder<ExistingDatabaseContext>();
switch (_options.DatabaseType)
{
case Enums.DatabaseType.SqlServer:
optionsBuilder.UseSqlServer(newConnectionString, options =>
{
if (_options.CommandTimeout > 0)
options.CommandTimeout(_options.CommandTimeout);
});
break;
default:
throw new NotSupportedException($"Database type {_options.DatabaseType} is not supported");
}
// Disponi il vecchio contesto e crea quello nuovo
_context.Dispose();
_context = new ExistingDatabaseContext(
optionsBuilder.Options,
_options.ModelConfigurator,
_options.EnableAutoDiscovery,
_options.EntityAssembly,
_options.EntityNamespace,
_options.NamingStrategy);
// Testa la connessione al nuovo database
await _context.Database.OpenConnectionAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Errore nel cambio database a '{databaseName}': {ex.Message}");
throw;
}
}
/// <summary>
/// Estrae la connection string del server (senza database specifico) da una connection string completa
/// </summary>
private string GetServerConnectionString(string connectionString)
{
var builder = new SqlConnectionStringBuilder(connectionString);
builder.InitialCatalog = ""; // Rimuove il database specifico
return builder.ConnectionString;
}
/// <summary>
/// Aggiorna la connection string con un nuovo database
/// </summary>
private string UpdateConnectionStringDatabase(string connectionString, string databaseName)
{
var builder = new SqlConnectionStringBuilder(connectionString);
builder.InitialCatalog = databaseName;
return builder.ConnectionString;
}
/// <summary>
/// Crea una connessione database appropriata in base al tipo di database
/// </summary>
@@ -222,4 +320,58 @@ public class EFCoreDatabaseManager : IDatabaseManager
{
_context?.Dispose();
}
public async Task<string?> GetPrimaryKeyFieldAsync(string tableName)
{
try
{
var connectionString = _context.Database.GetConnectionString();
if (connectionString == null)
throw new InvalidOperationException("Connection string is null");
using var connection = CreateConnection(connectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
// Query per ottenere la Primary Key della tabella
// Gestisce anche tabelle con schema (es. "dbo.TableName")
string schemaName = "dbo"; // Default schema
string tableNameOnly = tableName;
if (tableName.Contains('.'))
{
var parts = tableName.Split('.');
schemaName = parts[0];
tableNameOnly = parts[1];
}
command.CommandText = @"
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE OBJECTPROPERTY(OBJECT_ID(CONSTRAINT_SCHEMA + '.' + QUOTENAME(CONSTRAINT_NAME)), 'IsPrimaryKey') = 1
AND TABLE_SCHEMA = @schemaName
AND TABLE_NAME = @tableName
ORDER BY ORDINAL_POSITION";
// Usa parametri per evitare SQL injection
var schemaParam = command.CreateParameter();
schemaParam.ParameterName = "@schemaName";
schemaParam.Value = schemaName;
command.Parameters.Add(schemaParam);
var tableParam = command.CreateParameter();
tableParam.ParameterName = "@tableName";
tableParam.Value = tableNameOnly;
command.Parameters.Add(tableParam);
var result = await command.ExecuteScalarAsync();
return result?.ToString();
}
catch (Exception ex)
{
Console.WriteLine($"Errore nel recupero della Primary Key per la tabella {tableName}: {ex.Message}");
return null;
}
}
}
@@ -17,13 +17,24 @@ public class SqlServerSchemaProvider : IDatabaseSchemaProvider
try
{
Console.WriteLine($"[DEBUG] SqlServerSchemaProvider - Connection string: {connectionString?.Substring(0, Math.Min(50, connectionString?.Length ?? 0))}...");
using (var connection = new SqlConnection(connectionString))
{
await connection.OpenAsync();
Console.WriteLine($"[DEBUG] SqlServerSchemaProvider - Connessione aperta");
// Prima verifichiamo se ci sono tabelle utente con una query semplice
string testSql = "SELECT COUNT(*) FROM sys.tables WHERE is_ms_shipped = 0";
using (var testCommand = new SqlCommand(testSql, connection))
{
var scalarResult = await testCommand.ExecuteScalarAsync();
var tableCount = scalarResult != null ? (int)scalarResult : 0;
if (tableCount == 0)
{
return new Dictionary<string, IEnumerable<DbColumnInfo>>(); // Restituisce dizionario vuoto
}
}
// Se ci sono tabelle, procediamo con la query completa
// Query per ottenere la struttura delle tabelle in SQL Server
string sql = @"
SELECT
@@ -71,8 +82,8 @@ public class SqlServerSchemaProvider : IDatabaseSchemaProvider
using (var reader = await command.ExecuteReaderAsync())
{
string currentTable = null;
List<DbColumnInfo> columns = null;
string? currentTable = null;
List<DbColumnInfo>? columns = null;
while (await reader.ReadAsync())
{
@@ -117,12 +128,6 @@ public class SqlServerSchemaProvider : IDatabaseSchemaProvider
{
result[currentTable] = columns;
}
Console.WriteLine($"[DEBUG] SqlServerSchemaProvider - Query completata. Trovate {result.Count} tabelle");
foreach (var table in result.Take(3))
{
Console.WriteLine($"[DEBUG] SqlServerSchemaProvider - Tabella: {table.Key}, Colonne: {table.Value?.Count() ?? 0}");
}
}
}
}
@@ -45,6 +45,16 @@ public interface IDatabaseManager : IDisposable
/// Esegue un comando SQL che non restituisce risultati
/// </summary>
Task<int> ExecuteCommandAsync(string sql, params object[] parameters);
/// <summary>
/// Ottiene l'elenco dei database disponibili sul server
/// </summary>
Task<List<string>> GetAvailableDatabasesAsync();
/// <summary>
/// Cambia il database corrente per la connessione
/// </summary>
Task ChangeDatabaseAsync(string databaseName);
/// <summary>
/// Ottiene i metadati delle tabelle nel database
/// </summary>
@@ -54,6 +64,11 @@ public interface IDatabaseManager : IDisposable
/// Ottiene tutti i record da una tabella specifica come dizionari chiave-valore
/// </summary>
Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsAsync(string tableName);
/// <summary>
/// Ottiene il nome del campo Primary Key di una tabella specifica
/// </summary>
Task<string?> GetPrimaryKeyFieldAsync(string tableName);
}
/// <summary>
@@ -61,11 +76,11 @@ public interface IDatabaseManager : IDisposable
/// </summary>
public class DbColumnInfo
{
public string Name { get; set; }
public string DataType { get; set; }
public string Name { get; set; } = string.Empty;
public string DataType { get; set; } = string.Empty;
public bool IsNullable { get; set; }
public bool IsPrimaryKey { get; set; }
public bool IsForeignKey { get; set; }
public string ReferencedTable { get; set; }
public string ReferencedColumn { get; set; }
public string? ReferencedTable { get; set; }
public string? ReferencedColumn { get; set; }
}
@@ -135,6 +135,36 @@ namespace DataConnection.REST.Implementations
return await CreateEntityAsync(entityName, entityData, cancellationToken);
}
public virtual async Task<List<Dictionary<string, object>>> FindEntitiesByKeysAsync(string entityName, Dictionary<string, object> keyFields, CancellationToken cancellationToken = default)
{
// Default implementation - returns empty list
// Derived classes should override this method for service-specific entity search logic
await Task.CompletedTask;
return new List<Dictionary<string, object>>();
}
public virtual async Task<bool> DeleteEntityAsync(string entityName, string entityId, CancellationToken cancellationToken = default)
{
// Default implementation - returns false (not supported)
// Derived classes should override this method for service-specific entity deletion logic
await Task.CompletedTask;
return false;
} public virtual async Task<Dictionary<string, object>?> UpdateEntityAsync(string entityName, string entityId, Dictionary<string, object> entityData, CancellationToken cancellationToken = default)
{
// Default implementation - returns null (not supported)
// Derived classes should override this method for service-specific entity update logic
await Task.CompletedTask;
return null;
}
public virtual async Task<List<Dictionary<string, object>>> FindEntitiesByRequiredFieldsAsync(string entityName, Dictionary<string, object> requiredFields, CancellationToken cancellationToken = default)
{
// Default implementation - returns empty list (not supported)
// Derived classes should override this method for service-specific duplicate detection logic
await Task.CompletedTask;
return new List<Dictionary<string, object>>();
}
public virtual async Task<bool> AuthenticateAsync(CancellationToken cancellationToken = default)
{
// Default implementation for basic authentication (already handled in ConfigureHttpClient)
@@ -481,6 +481,176 @@ namespace DataConnection.REST.Implementations
Console.WriteLine($"Error during Salesforce entity upsert: {ex.Message}");
return null;
}
} /// <summary>
/// Finds entities by their key fields in Salesforce.
/// </summary>
/// <param name="entityName">The name of the SObject to search (e.g., "Account", "Contact").</param>
/// <param name="keyFields">The key fields and their values to match.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of matching entities or an empty list if none found.</returns>
public override async Task<List<Dictionary<string, object>>> FindEntitiesByKeysAsync(string entityName, Dictionary<string, object> keyFields, CancellationToken cancellationToken = default)
{
try
{
Console.WriteLine($"--- Starting Salesforce Entity Search: {entityName} ---");
Console.WriteLine($"Key Fields: {string.Join(", ", keyFields.Select(kvp => $"{kvp.Key}={kvp.Value}"))}");
if (!await EnsureAuthenticatedAsync(cancellationToken))
{
Console.WriteLine("Authentication failed for entity search");
return new List<Dictionary<string, object>>();
}
// Costruisci la query SOQL
var whereConditions = keyFields.Select(kvp =>
{
var value = kvp.Value?.ToString() ?? "";
// Se il valore è una stringa, aggiungi le virgolette
if (kvp.Value is string)
{
value = $"'{value.Replace("'", "\\'")}'"; // Escape delle virgolette
}
return $"{kvp.Key} = {value}";
});
var query = $"SELECT Id FROM {entityName} WHERE {string.Join(" AND ", whereConditions)}";
Console.WriteLine($"SOQL Query: {query}");
var encodedQuery = Uri.EscapeDataString(query);
var queryEndpoint = $"/services/data/v59.0/query/?q={encodedQuery}"; var response = await GetAsync<SalesforceQueryResponse>(queryEndpoint, cancellationToken);
if (response?.Records != null)
{
var results = response.Records.Select(record =>
record as Dictionary<string, object> ?? new Dictionary<string, object>()
).ToList();
Console.WriteLine($"Found {results.Count} entities matching the key fields");
return results;
}
Console.WriteLine("No entities found matching the key fields");
return new List<Dictionary<string, object>>();
}
catch (Exception ex)
{
Console.WriteLine($"Error during Salesforce entity search: {ex.Message}");
return new List<Dictionary<string, object>>();
}
} /// <summary>
/// Deletes an entity in Salesforce by its ID.
/// </summary>
/// <param name="entityName">The name of the SObject (e.g., "Account", "Contact").</param>
/// <param name="entityId">The ID of the entity to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deletion was successful, false otherwise.</returns>
public override async Task<bool> DeleteEntityAsync(string entityName, string entityId, CancellationToken cancellationToken = default)
{
try
{
Console.WriteLine($"--- Starting Salesforce Entity Delete: {entityName}/{entityId} ---");
if (!await EnsureAuthenticatedAsync(cancellationToken))
{
Console.WriteLine("Authentication failed for entity deletion");
return false;
}
var deleteEndpoint = $"/services/data/v59.0/sobjects/{entityName}/{entityId}";
// Salesforce usa DELETE HTTP method per eliminare record
var request = new HttpRequestMessage(HttpMethod.Delete, $"{_instanceUrl}{deleteEndpoint}");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
Console.WriteLine($"Entity {entityName}/{entityId} deleted successfully");
return true;
}
else
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
Console.WriteLine($"Failed to delete entity {entityName}/{entityId}. Status: {response.StatusCode}, Error: {errorContent}");
return false;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error during Salesforce entity deletion: {ex.Message}");
return false;
}
}
/// <summary>
/// Updates an existing entity in Salesforce by its ID.
/// </summary>
/// <param name="entityName">The name of the SObject (e.g., "Account", "Contact").</param>
/// <param name="entityId">The ID of the entity to update.</param>
/// <param name="entityData">The data to update as key-value pairs.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated entity data or null if update failed.</returns>
public override async Task<Dictionary<string, object>?> UpdateEntityAsync(string entityName, string entityId, Dictionary<string, object> entityData, CancellationToken cancellationToken = default)
{
try
{
Console.WriteLine($"--- Starting Salesforce Entity Update: {entityName}/{entityId} ---");
if (!await EnsureAuthenticatedAsync(cancellationToken))
{
Console.WriteLine("Authentication failed for entity update");
return null;
}
var updateEndpoint = $"/services/data/v59.0/sobjects/{entityName}/{entityId}";
// Salesforce usa PATCH HTTP method per aggiornare record
var request = new HttpRequestMessage(HttpMethod.Patch, $"{_instanceUrl}{updateEndpoint}");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
request.Content = JsonContent.Create(entityData);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
Console.WriteLine($"Entity {entityName}/{entityId} updated successfully");
// Ritorna i dati aggiornati includendo l'ID
var updatedData = new Dictionary<string, object>(entityData)
{
["Id"] = entityId
};
return updatedData;
}
else
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
Console.WriteLine($"Failed to update entity {entityName}/{entityId}. Status: {response.StatusCode}, Error: {errorContent}");
return null;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error during Salesforce entity update: {ex.Message}");
return null;
}
}
/// <summary>
/// Ensures the client is authenticated, attempting to authenticate if not already authenticated.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if authentication is successful, false otherwise.</returns>
private async Task<bool> EnsureAuthenticatedAsync(CancellationToken cancellationToken = default)
{
if (IsAuthenticated())
{
return true;
}
Console.WriteLine("Client not authenticated, attempting to authenticate...");
return await AuthenticateAsync(cancellationToken);
}
// --- Nested classes for deserializing Salesforce responses ---
@@ -541,7 +711,77 @@ namespace DataConnection.REST.Implementations
public string Label { get; set; } = string.Empty;
[JsonPropertyName("fields")]
public List<SalesforceField> Fields { get; set; } = new List<SalesforceField>();
public List<SalesforceField> Fields { get; set; } = new List<SalesforceField>(); } /// <summary>
/// Finds entities by required fields to detect duplicates.
/// </summary>
/// <param name="entityName">The name of the entity to search.</param>
/// <param name="requiredFields">The required fields and their values to search for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of matching entities.</returns>
public override async Task<List<Dictionary<string, object>>> FindEntitiesByRequiredFieldsAsync(string entityName, Dictionary<string, object> requiredFields, CancellationToken cancellationToken = default)
{
try
{
Console.WriteLine($"--- Searching for duplicates in {entityName} by required fields ---");
if (!await EnsureAuthenticatedAsync(cancellationToken))
{
Console.WriteLine("Authentication failed for required fields search");
return new List<Dictionary<string, object>>();
}
if (!requiredFields.Any())
{
Console.WriteLine("No required fields provided for duplicate search");
return new List<Dictionary<string, object>>();
} // Build WHERE clause with required fields
var whereConditions = new List<string>();
foreach (var field in requiredFields)
{
if (field.Value != null)
{
var value = field.Value.ToString();
// Escape single quotes in string values
if (field.Value is string stringValue)
{
value = stringValue.Replace("'", "\\'");
whereConditions.Add($"{field.Key} = '{value}'");
}
else
{
whereConditions.Add($"{field.Key} = {value}");
}
}
}
if (!whereConditions.Any())
{
Console.WriteLine("No valid field values provided for duplicate search");
return new List<Dictionary<string, object>>();
}
var whereClause = string.Join(" AND ", whereConditions);
var query = $"SELECT Id, {string.Join(", ", requiredFields.Keys)} FROM {entityName} WHERE {whereClause}";
Console.WriteLine($"Executing duplicate search query: {query}");
var queryEndpoint = $"/services/data/v59.0/query?q={Uri.EscapeDataString(query)}";
var response = await GetAsync<SalesforceQueryResponse>($"{_instanceUrl}{queryEndpoint}", cancellationToken);
if (response?.Records != null)
{
Console.WriteLine($"Found {response.Records.Count} potential duplicates for required fields: {string.Join(", ", requiredFields.Select(kv => $"{kv.Key}={kv.Value}"))}");
return response.Records;
}
Console.WriteLine("No duplicates found");
return new List<Dictionary<string, object>>();
}
catch (Exception ex)
{
Console.WriteLine($"Error during required fields search: {ex.Message}");
return new List<Dictionary<string, object>>();
}
}
private class SalesforceField
@@ -564,5 +804,11 @@ namespace DataConnection.REST.Implementations
[JsonPropertyName("unique")]
public bool Unique { get; set; }
}
private class SalesforceQueryResponse
{
[JsonPropertyName("records")]
public List<Dictionary<string, object>> Records { get; set; } = new List<Dictionary<string, object>>();
}
}
}
@@ -42,9 +42,7 @@ namespace DataConnection.REST.Interfaces
/// <param name="entityData">The data for the new entity as key-value pairs.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created entity data or null if creation failed.</returns>
Task<Dictionary<string, object>?> CreateEntityAsync(string entityName, Dictionary<string, object> entityData, CancellationToken cancellationToken = default);
/// <summary>
Task<Dictionary<string, object>?> CreateEntityAsync(string entityName, Dictionary<string, object> entityData, CancellationToken cancellationToken = default); /// <summary>
/// Creates a new entity or updates an existing one (upsert operation).
/// </summary>
/// <param name="entityName">The name of the entity to upsert.</param>
@@ -53,6 +51,41 @@ namespace DataConnection.REST.Interfaces
/// <returns>The upserted entity data or null if operation failed.</returns>
Task<Dictionary<string, object>?> UpsertEntityAsync(string entityName, Dictionary<string, object> entityData, CancellationToken cancellationToken = default);
/// <summary>
/// Searches for entities matching the specified key fields.
/// </summary>
/// <param name="entityName">The name of the entity to search.</param>
/// <param name="keyFields">The key fields and their values to search for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of matching entities.</returns>
Task<List<Dictionary<string, object>>> FindEntitiesByKeysAsync(string entityName, Dictionary<string, object> keyFields, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an entity by its ID or unique identifier.
/// </summary>
/// <param name="entityName">The name of the entity to delete.</param>
/// <param name="entityId">The ID or unique identifier of the entity to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if deletion was successful, false otherwise.</returns>
Task<bool> DeleteEntityAsync(string entityName, string entityId, CancellationToken cancellationToken = default); /// <summary>
/// Updates an existing entity by its ID with the provided data.
/// </summary>
/// <param name="entityName">The name of the entity to update.</param>
/// <param name="entityId">The ID or unique identifier of the entity to update.</param>
/// <param name="entityData">The data to update as key-value pairs.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated entity data or null if update failed.</returns>
Task<Dictionary<string, object>?> UpdateEntityAsync(string entityName, string entityId, Dictionary<string, object> entityData, CancellationToken cancellationToken = default);
/// <summary>
/// Searches for entities matching the specified required fields to detect duplicates.
/// </summary>
/// <param name="entityName">The name of the entity to search.</param>
/// <param name="requiredFields">The required fields and their values to search for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of matching entities.</returns>
Task<List<Dictionary<string, object>>> FindEntitiesByRequiredFieldsAsync(string entityName, Dictionary<string, object> requiredFields, CancellationToken cancellationToken = default);
// Add other methods as needed (PUT, DELETE, PATCH, etc.)
// Consider adding methods for handling raw HttpResponseMessage or string responses
}