fix(transport): Overwrite resolution now syncs child collections (2 findings)
Transport-001: template Overwrite now diff-and-merges the bundle's Attributes / Alarms / Scripts onto the target template via three private helpers (SyncTemplateAttributesAsync / SyncTemplateAlarmsAsync / SyncTemplateScriptsAsync). Each helper emits one audit row per detected add / update / delete and feeds the post-merge state into the existing ResolveAlarmScriptLinks and ResolveCompositionEdges passes. Transport-002: external-system Overwrite now syncs the Methods collection via a parallel SyncExternalSystemMethodsAsync helper mirroring the T-001 shape, with ExternalSystemMethodAdded / Updated / Deleted audit rows. Both fixes are covered by new integration tests in BundleImporterApplyTests. README regenerated — open findings dropped from 146 to 136; all 10 open High findings are now closed (0 Critical, 0 High, 46 Medium, 90 Low remaining).
This commit is contained in:
@@ -975,6 +975,17 @@ public sealed class BundleImporter : IBundleImporter
|
||||
await _templateRepo.UpdateTemplateAsync(ex, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Update", "Template", ex.Id.ToString(), ex.Name,
|
||||
new { ex.Name, ex.Description, ex.FolderId }, ct).ConfigureAwait(false);
|
||||
// T-001: Overwrite must also synchronise child collections —
|
||||
// attributes / alarms / scripts diverging between the bundle
|
||||
// and the target must round-trip. Composition rewire is
|
||||
// handled by ResolveCompositionEdgesAsync after the global
|
||||
// flush; alarm→script FKs are rewired by
|
||||
// ResolveAlarmScriptLinksAsync. The helpers below stage the
|
||||
// child diffs (add / update / delete) onto the tracked
|
||||
// entity and emit one audit row per detected change.
|
||||
await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false);
|
||||
await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false);
|
||||
await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false);
|
||||
summary.Overwritten++;
|
||||
break;
|
||||
case ResolutionAction.Add:
|
||||
@@ -1055,6 +1066,329 @@ public sealed class BundleImporter : IBundleImporter
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-001 — Overwrite child sync (attributes). Diffs the DTO's
|
||||
/// <c>Attributes</c> against the existing template's attribute collection
|
||||
/// by name and stages add / update / delete on the tracked entity. Emits
|
||||
/// one <c>TemplateAttributeAdded</c> / <c>TemplateAttributeUpdated</c> /
|
||||
/// <c>TemplateAttributeDeleted</c> audit row per detected change — the
|
||||
/// per-field rows the design doc's Configuration Audit Trail table
|
||||
/// enumerates for the "Template overwritten" action.
|
||||
/// <para>
|
||||
/// Update detection compares every scalar field (Value, DataType,
|
||||
/// IsLocked, Description, DataSourceReference) — no field change → no
|
||||
/// audit row, so an idempotent overwrite produces no noise.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task SyncTemplateAttributesAsync(
|
||||
Template ex,
|
||||
TemplateDto dto,
|
||||
string user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var existingByName = ex.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
|
||||
var dtoByName = dto.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
|
||||
|
||||
// Deletes — attributes present on the target but not in the bundle.
|
||||
foreach (var existing in existingByName.Values.ToList())
|
||||
{
|
||||
if (dtoByName.ContainsKey(existing.Name)) continue;
|
||||
await _templateRepo.DeleteTemplateAttributeAsync(existing.Id, ct).ConfigureAwait(false);
|
||||
ex.Attributes.Remove(existing);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateAttributeDeleted",
|
||||
"TemplateAttribute",
|
||||
existing.Id.ToString(),
|
||||
$"{ex.Name}.{existing.Name}",
|
||||
new { TemplateName = ex.Name, AttributeName = existing.Name },
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Adds + Updates.
|
||||
foreach (var attrDto in dto.Attributes)
|
||||
{
|
||||
if (existingByName.TryGetValue(attrDto.Name, out var current))
|
||||
{
|
||||
// Update only if any field actually changed.
|
||||
bool changed =
|
||||
!string.Equals(current.Value, attrDto.Value, StringComparison.Ordinal) ||
|
||||
current.DataType != attrDto.DataType ||
|
||||
current.IsLocked != attrDto.IsLocked ||
|
||||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal);
|
||||
if (!changed) continue;
|
||||
|
||||
current.Value = attrDto.Value;
|
||||
current.DataType = attrDto.DataType;
|
||||
current.IsLocked = attrDto.IsLocked;
|
||||
current.Description = attrDto.Description;
|
||||
current.DataSourceReference = attrDto.DataSourceReference;
|
||||
await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateAttributeUpdated",
|
||||
"TemplateAttribute",
|
||||
current.Id.ToString(),
|
||||
$"{ex.Name}.{current.Name}",
|
||||
new
|
||||
{
|
||||
TemplateName = ex.Name,
|
||||
AttributeName = current.Name,
|
||||
current.Value,
|
||||
current.DataType,
|
||||
current.IsLocked,
|
||||
current.Description,
|
||||
current.DataSourceReference,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newAttr = new TemplateAttribute(attrDto.Name)
|
||||
{
|
||||
Value = attrDto.Value,
|
||||
DataType = attrDto.DataType,
|
||||
IsLocked = attrDto.IsLocked,
|
||||
Description = attrDto.Description,
|
||||
DataSourceReference = attrDto.DataSourceReference,
|
||||
};
|
||||
ex.Attributes.Add(newAttr);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateAttributeAdded",
|
||||
"TemplateAttribute",
|
||||
"0",
|
||||
$"{ex.Name}.{newAttr.Name}",
|
||||
new
|
||||
{
|
||||
TemplateName = ex.Name,
|
||||
AttributeName = newAttr.Name,
|
||||
newAttr.Value,
|
||||
newAttr.DataType,
|
||||
newAttr.IsLocked,
|
||||
newAttr.Description,
|
||||
newAttr.DataSourceReference,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-001 — Overwrite child sync (alarms). Mirrors
|
||||
/// <see cref="SyncTemplateAttributesAsync"/> for the alarm collection.
|
||||
/// Updated / added alarms have their <c>OnTriggerScriptId</c> cleared so
|
||||
/// the post-flush <see cref="ResolveAlarmScriptLinksAsync"/> pass re-binds
|
||||
/// the FK from the DTO's <c>OnTriggerScriptName</c> against the synced
|
||||
/// script collection. Audit rows: <c>TemplateAlarmAdded</c> /
|
||||
/// <c>TemplateAlarmUpdated</c> / <c>TemplateAlarmDeleted</c>.
|
||||
/// </summary>
|
||||
private async Task SyncTemplateAlarmsAsync(
|
||||
Template ex,
|
||||
TemplateDto dto,
|
||||
string user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var existingByName = ex.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
|
||||
var dtoByName = dto.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
|
||||
|
||||
foreach (var existing in existingByName.Values.ToList())
|
||||
{
|
||||
if (dtoByName.ContainsKey(existing.Name)) continue;
|
||||
await _templateRepo.DeleteTemplateAlarmAsync(existing.Id, ct).ConfigureAwait(false);
|
||||
ex.Alarms.Remove(existing);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateAlarmDeleted",
|
||||
"TemplateAlarm",
|
||||
existing.Id.ToString(),
|
||||
$"{ex.Name}.{existing.Name}",
|
||||
new { TemplateName = ex.Name, AlarmName = existing.Name },
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var alarmDto in dto.Alarms)
|
||||
{
|
||||
if (existingByName.TryGetValue(alarmDto.Name, out var current))
|
||||
{
|
||||
bool changed =
|
||||
!string.Equals(current.Description, alarmDto.Description, StringComparison.Ordinal) ||
|
||||
current.PriorityLevel != alarmDto.PriorityLevel ||
|
||||
current.TriggerType != alarmDto.TriggerType ||
|
||||
!string.Equals(current.TriggerConfiguration, alarmDto.TriggerConfiguration, StringComparison.Ordinal) ||
|
||||
current.IsLocked != alarmDto.IsLocked;
|
||||
if (!changed)
|
||||
{
|
||||
// Always reset the script FK on Overwrite so the post-flush
|
||||
// resolve pass owns the binding (the DTO's script name is
|
||||
// the authoritative reference); leaving a stale FK would
|
||||
// silently survive Overwrite when the user expected the
|
||||
// bundle to be the source of truth.
|
||||
if ((current.OnTriggerScriptId is not null) ||
|
||||
!string.IsNullOrEmpty(alarmDto.OnTriggerScriptName))
|
||||
{
|
||||
current.OnTriggerScriptId = null;
|
||||
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current.Description = alarmDto.Description;
|
||||
current.PriorityLevel = alarmDto.PriorityLevel;
|
||||
current.TriggerType = alarmDto.TriggerType;
|
||||
current.TriggerConfiguration = alarmDto.TriggerConfiguration;
|
||||
current.IsLocked = alarmDto.IsLocked;
|
||||
current.OnTriggerScriptId = null; // re-resolved post-flush.
|
||||
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateAlarmUpdated",
|
||||
"TemplateAlarm",
|
||||
current.Id.ToString(),
|
||||
$"{ex.Name}.{current.Name}",
|
||||
new
|
||||
{
|
||||
TemplateName = ex.Name,
|
||||
AlarmName = current.Name,
|
||||
current.Description,
|
||||
current.PriorityLevel,
|
||||
current.TriggerType,
|
||||
current.TriggerConfiguration,
|
||||
current.IsLocked,
|
||||
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newAlarm = new TemplateAlarm(alarmDto.Name)
|
||||
{
|
||||
Description = alarmDto.Description,
|
||||
PriorityLevel = alarmDto.PriorityLevel,
|
||||
TriggerType = alarmDto.TriggerType,
|
||||
TriggerConfiguration = alarmDto.TriggerConfiguration,
|
||||
IsLocked = alarmDto.IsLocked,
|
||||
};
|
||||
ex.Alarms.Add(newAlarm);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateAlarmAdded",
|
||||
"TemplateAlarm",
|
||||
"0",
|
||||
$"{ex.Name}.{newAlarm.Name}",
|
||||
new
|
||||
{
|
||||
TemplateName = ex.Name,
|
||||
AlarmName = newAlarm.Name,
|
||||
newAlarm.Description,
|
||||
newAlarm.PriorityLevel,
|
||||
newAlarm.TriggerType,
|
||||
newAlarm.TriggerConfiguration,
|
||||
newAlarm.IsLocked,
|
||||
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-001 — Overwrite child sync (scripts). Mirrors
|
||||
/// <see cref="SyncTemplateAttributesAsync"/> for the script collection.
|
||||
/// Audit rows: <c>TemplateScriptAdded</c> / <c>TemplateScriptUpdated</c> /
|
||||
/// <c>TemplateScriptDeleted</c>.
|
||||
/// </summary>
|
||||
private async Task SyncTemplateScriptsAsync(
|
||||
Template ex,
|
||||
TemplateDto dto,
|
||||
string user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var existingByName = ex.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
||||
var dtoByName = dto.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
||||
|
||||
foreach (var existing in existingByName.Values.ToList())
|
||||
{
|
||||
if (dtoByName.ContainsKey(existing.Name)) continue;
|
||||
await _templateRepo.DeleteTemplateScriptAsync(existing.Id, ct).ConfigureAwait(false);
|
||||
ex.Scripts.Remove(existing);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateScriptDeleted",
|
||||
"TemplateScript",
|
||||
existing.Id.ToString(),
|
||||
$"{ex.Name}.{existing.Name}",
|
||||
new { TemplateName = ex.Name, ScriptName = existing.Name },
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var scriptDto in dto.Scripts)
|
||||
{
|
||||
if (existingByName.TryGetValue(scriptDto.Name, out var current))
|
||||
{
|
||||
bool changed =
|
||||
!string.Equals(current.Code, scriptDto.Code, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.TriggerType, scriptDto.TriggerType, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.TriggerConfiguration, scriptDto.TriggerConfiguration, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.ParameterDefinitions, scriptDto.ParameterDefinitions, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.ReturnDefinition, scriptDto.ReturnDefinition, StringComparison.Ordinal) ||
|
||||
current.IsLocked != scriptDto.IsLocked;
|
||||
if (!changed) continue;
|
||||
|
||||
current.Code = scriptDto.Code;
|
||||
current.TriggerType = scriptDto.TriggerType;
|
||||
current.TriggerConfiguration = scriptDto.TriggerConfiguration;
|
||||
current.ParameterDefinitions = scriptDto.ParameterDefinitions;
|
||||
current.ReturnDefinition = scriptDto.ReturnDefinition;
|
||||
current.IsLocked = scriptDto.IsLocked;
|
||||
await _templateRepo.UpdateTemplateScriptAsync(current, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateScriptUpdated",
|
||||
"TemplateScript",
|
||||
current.Id.ToString(),
|
||||
$"{ex.Name}.{current.Name}",
|
||||
new
|
||||
{
|
||||
TemplateName = ex.Name,
|
||||
ScriptName = current.Name,
|
||||
current.TriggerType,
|
||||
current.TriggerConfiguration,
|
||||
current.IsLocked,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newScript = new TemplateScript(scriptDto.Name, scriptDto.Code)
|
||||
{
|
||||
TriggerType = scriptDto.TriggerType,
|
||||
TriggerConfiguration = scriptDto.TriggerConfiguration,
|
||||
ParameterDefinitions = scriptDto.ParameterDefinitions,
|
||||
ReturnDefinition = scriptDto.ReturnDefinition,
|
||||
IsLocked = scriptDto.IsLocked,
|
||||
};
|
||||
ex.Scripts.Add(newScript);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"TemplateScriptAdded",
|
||||
"TemplateScript",
|
||||
"0",
|
||||
$"{ex.Name}.{newScript.Name}",
|
||||
new
|
||||
{
|
||||
TemplateName = ex.Name,
|
||||
ScriptName = newScript.Name,
|
||||
newScript.TriggerType,
|
||||
newScript.TriggerConfiguration,
|
||||
newScript.IsLocked,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FU-B / remainder of #37 — Pass A of the post-template-flush rewire.
|
||||
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
|
||||
@@ -1345,6 +1679,12 @@ public sealed class BundleImporter : IBundleImporter
|
||||
await _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
|
||||
new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false);
|
||||
// T-002: Overwrite must also synchronise the Methods child
|
||||
// collection — added / removed / modified methods on the
|
||||
// bundle DTO must round-trip. Mirrors the T-001 template
|
||||
// child-sync helpers (attributes / alarms / scripts): each
|
||||
// helper emits one audit row per detected change.
|
||||
await SyncExternalSystemMethodsAsync(existing, dto, user, ct).ConfigureAwait(false);
|
||||
summary.Overwritten++;
|
||||
break;
|
||||
case ResolutionAction.Add:
|
||||
@@ -1371,6 +1711,114 @@ public sealed class BundleImporter : IBundleImporter
|
||||
return sys;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-002 — Overwrite child sync (ExternalSystem methods). Mirrors the
|
||||
/// T-001 <c>SyncTemplate*Async</c> helpers: name-keyed diff between the
|
||||
/// bundle DTO and the persisted children, then add / update / delete via
|
||||
/// the repository with one audit row per detected change. Methods are
|
||||
/// NOT a navigation on <see cref="ExternalSystemDefinition"/> (the FK
|
||||
/// runs from <see cref="ExternalSystemMethod.ExternalSystemDefinitionId"/>
|
||||
/// to the parent) so the helper drives the repo directly rather than
|
||||
/// mutating a tracked collection like the template helpers do.
|
||||
/// <para>
|
||||
/// Audit rows: <c>ExternalSystemMethodAdded</c> /
|
||||
/// <c>ExternalSystemMethodUpdated</c> / <c>ExternalSystemMethodDeleted</c>.
|
||||
/// Idempotent: scalar-field comparison gates the Update audit row, so an
|
||||
/// Overwrite against an already-matching method produces no noise.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task SyncExternalSystemMethodsAsync(
|
||||
ExternalSystemDefinition ex,
|
||||
ExternalSystemDto dto,
|
||||
string user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var existingMethods = await _externalRepo
|
||||
.GetMethodsByExternalSystemIdAsync(ex.Id, ct)
|
||||
.ConfigureAwait(false);
|
||||
var existingByName = existingMethods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
|
||||
var dtoByName = dto.Methods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
|
||||
|
||||
// Deletes — methods present on the target but not in the bundle.
|
||||
foreach (var existing in existingByName.Values.ToList())
|
||||
{
|
||||
if (dtoByName.ContainsKey(existing.Name)) continue;
|
||||
await _externalRepo.DeleteExternalSystemMethodAsync(existing.Id, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"ExternalSystemMethodDeleted",
|
||||
"ExternalSystemMethod",
|
||||
existing.Id.ToString(),
|
||||
$"{ex.Name}.{existing.Name}",
|
||||
new { ExternalSystemName = ex.Name, MethodName = existing.Name },
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Adds + Updates.
|
||||
foreach (var methodDto in dto.Methods)
|
||||
{
|
||||
if (existingByName.TryGetValue(methodDto.Name, out var current))
|
||||
{
|
||||
// Update only if any field actually changed — mirrors the
|
||||
// ArtifactDiff.ExternalSystemMethodsEqual comparator.
|
||||
bool changed =
|
||||
!string.Equals(current.HttpMethod, methodDto.HttpMethod, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.Path, methodDto.Path, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.ParameterDefinitions, methodDto.ParameterDefinitions, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.ReturnDefinition, methodDto.ReturnDefinition, StringComparison.Ordinal);
|
||||
if (!changed) continue;
|
||||
|
||||
current.HttpMethod = methodDto.HttpMethod;
|
||||
current.Path = methodDto.Path;
|
||||
current.ParameterDefinitions = methodDto.ParameterDefinitions;
|
||||
current.ReturnDefinition = methodDto.ReturnDefinition;
|
||||
await _externalRepo.UpdateExternalSystemMethodAsync(current, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"ExternalSystemMethodUpdated",
|
||||
"ExternalSystemMethod",
|
||||
current.Id.ToString(),
|
||||
$"{ex.Name}.{current.Name}",
|
||||
new
|
||||
{
|
||||
ExternalSystemName = ex.Name,
|
||||
MethodName = current.Name,
|
||||
current.HttpMethod,
|
||||
current.Path,
|
||||
current.ParameterDefinitions,
|
||||
current.ReturnDefinition,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newMethod = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path)
|
||||
{
|
||||
ExternalSystemDefinitionId = ex.Id,
|
||||
ParameterDefinitions = methodDto.ParameterDefinitions,
|
||||
ReturnDefinition = methodDto.ReturnDefinition,
|
||||
};
|
||||
await _externalRepo.AddExternalSystemMethodAsync(newMethod, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
"ExternalSystemMethodAdded",
|
||||
"ExternalSystemMethod",
|
||||
"0",
|
||||
$"{ex.Name}.{newMethod.Name}",
|
||||
new
|
||||
{
|
||||
ExternalSystemName = ex.Name,
|
||||
MethodName = newMethod.Name,
|
||||
newMethod.HttpMethod,
|
||||
newMethod.Path,
|
||||
newMethod.ParameterDefinitions,
|
||||
newMethod.ReturnDefinition,
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyDatabaseConnectionsAsync(
|
||||
IReadOnlyList<DatabaseConnectionDto> dtos,
|
||||
Dictionary<(string, string), ImportResolution> map,
|
||||
|
||||
Reference in New Issue
Block a user