Wenn ein xrm_tablemanagement Record gelöscht wird, löscht dieses Plugin automatisch alle zugehörigen Translation Records — über alle 7 abhängigen Entities, sauber paged und batch-optimiert.
PostOperation auf Delete — Pre-Image liefert xrm_entitylogicalname, danach sequenzielles Löschen aller 7 abhängigen Entities.
xrm_entitylogicalname aus dem Pre-Image des gelöschten xrm_tablemanagement Records auslesen. Ohne diesen Wert kann der Plugin nicht arbeiten — sofortiger Abbruch mit Trace.
Vor dem Löschen wird die Gesamtzahl betroffener Records gezählt. Liegt sie über dem konfigurierten Schwellenwert (Standard: 500 pro Entity), wird ein Warning geloggt — das Löschen läuft aber weiter.
Für jede der 7 abhängigen Entities: QueryExpression mit exaktem entitylogicalname-Filter, paged mit 250 Records/Page, Batch-Delete mit ExecuteMultiple in 50er-Batches. Fehler einer Entity stoppen nicht die anderen.
Nach allen Deletes wird ein Gesamt-Summary ins Trace geschrieben: wie viele Records je Entity gelöscht, wie viele Fehler aufgetreten sind.
Alle 7 abhängigen Translation Entities werden über xrm_entitylogicalname identifiziert.
Entry Point — liest Pre-Image, orchestriert den Service, schreibt Summary Log
using System; using Microsoft.Xrm.Sdk; using XrmTranslationStudio.Plugins.Services; namespace XrmTranslationStudio.Plugins { /// <summary> /// Cascade-deletes all related translation records when a /// xrm_tablemanagement record is deleted. /// /// Registration: /// Message: Delete /// Entity: xrm_tablemanagement /// Stage: PostOperation (40) /// Mode: Synchronous /// Pre-Image: alias "PreImage", attribute "xrm_entitylogicalname" /// </summary> public class CascadeDeletePlugin : IPlugin { public void Execute(IServiceProvider serviceProvider) { var context = (IPluginExecutionContext) serviceProvider.GetService(typeof(IPluginExecutionContext)); var serviceFactory = (IOrganizationServiceFactory) serviceProvider.GetService(typeof(IOrganizationServiceFactory)); var tracer = (ITracingService) serviceProvider.GetService(typeof(ITracingService)); var service = serviceFactory.CreateOrganizationService(context.UserId); tracer.Trace("[CascadeDelete] Plugin triggered."); // ── Validate execution context ──────────────────────────────────── if (context.MessageName.ToLowerInvariant() != "delete") { tracer.Trace("[CascadeDelete] Unexpected message. Exiting."); return; } // ── Read Pre-Image ──────────────────────────────────────────────── if (!context.PreEntityImages.Contains("PreImage")) { tracer.Trace("[CascadeDelete] PreImage 'PreImage' not found. Ensure pre-image is registered."); return; } var preImage = context.PreEntityImages["PreImage"]; var entityLogicalName = preImage.GetAttributeValue<string>("xrm_entitylogicalname"); if (string.IsNullOrWhiteSpace(entityLogicalName)) { tracer.Trace("[CascadeDelete] xrm_entitylogicalname is null or empty. Nothing to cascade."); return; } tracer.Trace($"[CascadeDelete] Cascade delete for entityLogicalName='{entityLogicalName}'"); // ── Execute cascade delete ──────────────────────────────────────── try { var cascadeService = new CascadeDeleteService(service, tracer); cascadeService.DeleteAllRelated(entityLogicalName); tracer.Trace("[CascadeDelete] Cascade delete completed."); } catch (Exception ex) { tracer.Trace($"[CascadeDelete] Unhandled exception: {ex.Message}\n{ex.StackTrace}"); // Do NOT rethrow — the parent delete has already succeeded. // Rethrowing would roll back the delete and confuse the user. } } } }
Kernlogik — iteriert alle 7 Entities, queried paged, löscht in Batches, loggt Partial Failures
using System; using System.Collections.Generic; using System.Text; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; using XrmTranslationStudio.Plugins.Helpers; namespace XrmTranslationStudio.Deployment.Services { public class CascadeDeleteService { private readonly IOrganizationService _service; private readonly ITracingService _tracer; // How many records to retrieve per query page private const int QueryPageSize = 250; // Safety threshold: log a warning if a single entity has more than this many records private const int SafeguardThreshold = 500; public CascadeDeleteService(IOrganizationService service, ITracingService tracer) { _service = service ?? throw new ArgumentNullException(nameof(service)); _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); } /// <summary> /// Deletes all related translation records for the given entityLogicalName. /// Processes each entity independently — a failure in one does not stop the others. /// </summary> public void DeleteAllRelated(string entityLogicalName) { var summary = new StringBuilder(); var totalDeleted = 0; var totalErrors = 0; summary.AppendLine($"=== Cascade Delete Summary for '{entityLogicalName}' ==="); foreach (var target in EntityConstants.CascadeTargets) { try { var deleted = DeleteByEntityLogicalName( target.EntityName, target.LogicalNameField, entityLogicalName); totalDeleted += deleted; summary.AppendLine($" {target.EntityName,-35} deleted: {deleted}"); _tracer.Trace($"[CascadeDelete] {target.EntityName}: {deleted} records deleted."); } catch (Exception ex) { totalErrors++; var msg = $" {target.EntityName,-35} ERROR: {ex.Message}"; summary.AppendLine(msg); _tracer.Trace($"[CascadeDelete] {msg}"); // Continue with next entity — partial failure is logged but non-fatal } } summary.AppendLine($" Total deleted: {totalDeleted} | Errors: {totalErrors}"); summary.AppendLine("==================================================="); _tracer.Trace(summary.ToString()); } /// <summary> /// Queries all records of the given entity matching the entityLogicalName, /// then deletes them in batches. Returns total number of deleted records. /// </summary> public int DeleteByEntityLogicalName( string targetEntity, string logicalNameField, string entityLogicalName) { if (string.IsNullOrWhiteSpace(targetEntity)) throw new ArgumentNullException(nameof(targetEntity)); if (string.IsNullOrWhiteSpace(logicalNameField)) throw new ArgumentNullException(nameof(logicalNameField)); if (string.IsNullOrWhiteSpace(entityLogicalName)) throw new ArgumentNullException(nameof(entityLogicalName)); var allRefs = RetrieveAllMatchingIds(targetEntity, logicalNameField, entityLogicalName); if (allRefs.Count == 0) { _tracer.Trace($"[CascadeDelete] {targetEntity}: no matching records found."); return 0; } if (allRefs.Count > SafeguardThreshold) { _tracer.Trace( $"[CascadeDelete] WARNING: {targetEntity} has {allRefs.Count} records " + $"exceeding safeguard threshold of {SafeguardThreshold}. Proceeding anyway."); } var batchHelper = new BatchDeleteHelper(_service, _tracer); return batchHelper.BatchDelete(allRefs); } /// <summary> /// Pages through all records of targetEntity where logicalNameField = entityLogicalName. /// Returns a flat list of EntityReferences for batch deletion. /// </summary> private List<EntityReference> RetrieveAllMatchingIds( string targetEntity, string logicalNameField, string entityLogicalName) { var refs = new List<EntityReference>(); var query = new QueryExpression(targetEntity) { ColumnSet = new ColumnSet(false), // only retrieve the id — no data needed Criteria = new FilterExpression(LogicalOperator.And), NoLock = true }; query.Criteria.AddCondition( logicalNameField, ConditionOperator.Equal, entityLogicalName.ToLowerInvariant()); query.PageInfo = new PagingInfo { Count = QueryPageSize, PageNumber = 1, ReturnTotalRecordCount = false }; while (true) { var result = _service.RetrieveMultiple(query); foreach (var entity in result.Entities) refs.Add(entity.ToEntityReference()); if (!result.MoreRecords) break; query.PageInfo.PageNumber++; query.PageInfo.PagingCookie = result.PagingCookie; } _tracer.Trace($"[CascadeDelete] {targetEntity}: found {refs.Count} records to delete."); return refs; } } }
ExecuteMultiple-basierter Batch-Delete mit per-Item Error Logging
using System; using System.Collections.Generic; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; namespace XrmTranslationStudio.Plugins.Helpers { /// <summary> /// Deletes a list of EntityReferences using ExecuteMultipleRequest in configurable chunks. /// ContinueOnError = true ensures a single failed delete does not abort the batch. /// </summary> public class BatchDeleteHelper { private readonly IOrganizationService _service; private readonly ITracingService _tracer; // ExecuteMultiple batch size — keep at 50 or below for Sandbox safety private const int BatchSize = 50; public BatchDeleteHelper(IOrganizationService service, ITracingService tracer) { _service = service ?? throw new ArgumentNullException(nameof(service)); _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); } /// <summary> /// Deletes all provided EntityReferences in batches. /// Returns the number of successfully deleted records. /// </summary> public int BatchDelete(List<EntityReference> refs) { if (refs == null || refs.Count == 0) return 0; var totalDeleted = 0; var batchNumber = 0; for (int i = 0; i < refs.Count; i += BatchSize) { batchNumber++; var chunk = refs.GetRange(i, Math.Min(BatchSize, refs.Count - i)); var deleted = ExecuteBatch(chunk, batchNumber); totalDeleted += deleted; } return totalDeleted; } private int ExecuteBatch(List<EntityReference> chunk, int batchNumber) { var request = new ExecuteMultipleRequest { Settings = new ExecuteMultipleSettings { ContinueOnError = true, ReturnResponses = true // needed to inspect per-item faults }, Requests = new OrganizationRequestCollection() }; foreach (var r in chunk) request.Requests.Add(new DeleteRequest { Target = r }); var succeeded = 0; var failed = 0; try { var response = (ExecuteMultipleResponse)_service.Execute(request); for (int i = 0; i < response.Responses.Count; i++) { var item = response.Responses[i]; if (item.Fault != null) { failed++; _tracer.Trace( $"[BatchDelete] Batch {batchNumber}, item {i}: " + $"DELETE failed for {chunk[i].LogicalName} {chunk[i].Id}: " + $"{item.Fault.Message}"); } else { succeeded++; } } if (failed > 0) _tracer.Trace( $"[BatchDelete] Batch {batchNumber}: {succeeded} OK, {failed} failed."); else _tracer.Trace( $"[BatchDelete] Batch {batchNumber}: all {succeeded} records deleted."); } catch (Exception ex) { // ExecuteMultiple itself failed — fall back to individual deletes _tracer.Trace( $"[BatchDelete] Batch {batchNumber} ExecuteMultiple failed: {ex.Message}. " + "Falling back to individual deletes."); succeeded = FallbackIndividualDelete(chunk); } return succeeded; } /// <summary> /// Fallback: deletes records one-by-one when ExecuteMultiple itself fails. /// Each failure is logged but does not stop the loop. /// </summary> private int FallbackIndividualDelete(List<EntityReference> chunk) { var deleted = 0; foreach (var r in chunk) { try { _service.Delete(r.LogicalName, r.Id); deleted++; } catch (Exception ex) { _tracer.Trace( $"[BatchDelete] Individual delete failed {r.LogicalName} {r.Id}: {ex.Message}"); } } return deleted; } } }
Zentrale Konfiguration — hier wird gesteuert welche Entities kaskadiert werden und über welches Feld
namespace XrmTranslationStudio.Plugins { /// <summary> /// Defines all entities that receive cascade deletes when a /// xrm_tablemanagement record is removed. /// /// To add/remove a target entity: edit CascadeTargets only. /// No other code changes required. /// </summary> public static class EntityConstants { public static readonly CascadeTarget[] CascadeTargets = { new CascadeTarget("xrm_attribute", "xrm_entitylogicalname"), new CascadeTarget("xrm_form", "xrm_entitylogicalname"), new CascadeTarget("xrm_tab", "xrm_entitylogicalname"), new CascadeTarget("xrm_sections", "xrm_entitylogicalname"), new CascadeTarget("xrm_view", "xrm_entitylogicalname"), new CascadeTarget("xrm_localoptionsetvalue", "xrm_entitylogicalname"), new CascadeTarget("xrm_globaloptionsetvalue", "xrm_entitylogicalname"), }; } public class CascadeTarget { public string EntityName { get; } public string LogicalNameField { get; } public CascadeTarget(string entityName, string logicalNameField) { EntityName = entityName; LogicalNameField = logicalNameField; } } }
Plugin Registration Tool — Schritt für Schritt
| # | Aktion | Wert |
|---|---|---|
| 1 | Register New Assembly | XrmTranslationStudio.Plugins.dll hochladen |
| 2 | Register New Step | Message: Delete, Entity: xrm_tablemanagement |
| 3 | Stage setzen | PostOperation (40) |
| 4 | Execution Mode | Synchronous |
| 5 | Register New Image | Typ: PreImage, Alias: PreImage |
| 6 | Image Attributes | xrm_entitylogicalname |
| 7 | Save & Test | Einen xrm_tablemanagement Record löschen und Trace Log prüfen |
Konfigurierbare Werte — anpassen je nach Datenmenge und Sandbox-Limit
| Entities | Records/Entity | Total Records | Batches (à 50) | Geschätzte Zeit |
|---|---|---|---|---|
| 7 | 100 | 700 | 14 | ~5–8 Sek. |
| 7 | 500 | 3.500 | 70 | ~25–40 Sek. |
| 7 | 2.000 | 14.000 | 280 | ⚠ Async empfohlen |
Bewusste Entscheidungen im Code erklärt
Der Plugin fängt alle Exceptions im top-level try/catch ohne Rethrow. Der Parent-Delete ist bereits committed — ein Rethrow würde den User mit einem Fehler konfrontieren ohne Rollback-Möglichkeit.
Wenn ExecuteMultiple selbst fehlschlägt (z.B. Service Unavailable), fällt der BatchDeleteHelper auf einzelne Delete-Calls zurück. Jeder Fehler wird geloggt, der Loop läuft weiter.
Verhindert Locking-Konflikte bei konkurrenten Operationen. Da wir nur IDs lesen und keine Konsistenzgarantie für den Query-Zeitpunkt benötigen, ist NoLock hier sicher.
Die abhängigen Entities (xrm_attribute, xrm_form, etc.) haben keinen eigenen Delete-Plugin, der xrm_tablemanagement triggert. Rekursion ist daher nicht möglich.
Global OptionSets sind entity-übergreifend. Falls das Feld xrm_entitylogicalname dort nicht existiert oder einen anderen Namen hat, muss EntityConstants entsprechend angepasst oder die Entity deaktiviert werden.
ColumnSet(false) mit ToEntityReference() ist die effizienteste Art nur IDs aus einer Query zu holen. Der Primary Key wird immer zurückgegeben, kein zusätzliches Attribut wird übertragen.
Dataverse Logical Names sind immer lowercase. Der Filter normalisiert den Wert aus dem Pre-Image auf Lowercase um Case-Mismatch-Fehler zu vermeiden.