using System.Text.Json; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// /// Default . Reads the Raw-tree structural rows with untracked queries, /// lazily materialising one level per expand, and applies structural mutations (create/rename/delete/ /// move) with last-write-wins optimistic concurrency on RowVersion — the same pattern /// uses for the UNS subtree. Integrity rules (sibling-name uniqueness, /// non-empty-container delete blocks, referenced-tag delete blocks) are pre-checked with user-readable /// errors so the refusal is deterministic even under the EF InMemory provider (which does not enforce /// the FK Restrict / filtered-unique indexes the real SQL schema carries). /// /// /// Each call creates and disposes its own context via the pooled factory, so the service is safe to /// register as Scoped and used per Blazor circuit. /// public sealed class RawTreeService(IDbContextFactory dbFactory) : IRawTreeService { // --------------------------------------------------------------------------------------------- // Read surface // --------------------------------------------------------------------------------------------- /// public async Task> LoadRootsAsync(CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var clusters = await db.ServerClusters .AsNoTracking() .Select(c => new ClusterRow(c.ClusterId, c.Enterprise, c.Site, c.Name)) .ToListAsync(ct); // Per-cluster root child count = root folders (ParentRawFolderId == null) + cluster-root drivers // (RawFolderId == null), for the cluster badge. var rootFolderCounts = (await db.RawFolders.AsNoTracking() .Where(f => f.ParentRawFolderId == null) .GroupBy(f => f.ClusterId) .Select(g => new { ClusterId = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal); var rootDriverCounts = (await db.DriverInstances.AsNoTracking() .Where(d => d.RawFolderId == null) .GroupBy(d => d.ClusterId) .Select(g => new { ClusterId = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal); var rootChildCounts = new Dictionary(StringComparer.Ordinal); foreach (var c in clusters) { rootChildCounts[c.ClusterId] = rootFolderCounts.GetValueOrDefault(c.ClusterId) + rootDriverCounts.GetValueOrDefault(c.ClusterId); } return RawTreeAssembly.BuildRoots(clusters, rootChildCounts); } /// public async Task> LoadChildrenAsync(RawNode parent, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(parent); if (parent.EntityId is null) { return Array.Empty(); } await using var db = await dbFactory.CreateDbContextAsync(ct); return parent.Kind switch { RawNodeKind.Cluster => await LoadClusterChildrenAsync(db, parent.EntityId, ct), RawNodeKind.Folder => await LoadFolderChildrenAsync(db, parent.ClusterId, parent.EntityId, ct), RawNodeKind.Driver => await LoadDriverChildrenAsync(db, parent, ct), RawNodeKind.Device => await LoadDeviceChildrenAsync(db, parent, ct), RawNodeKind.TagGroup => await LoadTagGroupChildrenAsync(db, parent, ct), _ => Array.Empty(), // Tag (leaf) / Enterprise }; } private static async Task> LoadClusterChildrenAsync( OtOpcUaConfigDbContext db, string clusterId, CancellationToken ct) { var folders = await db.RawFolders.AsNoTracking() .Where(f => f.ClusterId == clusterId && f.ParentRawFolderId == null) .OrderBy(f => f.SortOrder).ThenBy(f => f.Name) .ToListAsync(ct); var drivers = await db.DriverInstances.AsNoTracking() .Where(d => d.ClusterId == clusterId && d.RawFolderId == null) .OrderBy(d => d.Name) .ToListAsync(ct); var nodes = new List(folders.Count + drivers.Count); nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, folders, ct)); nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct)); return nodes; } private static async Task> LoadFolderChildrenAsync( OtOpcUaConfigDbContext db, string? clusterId, string folderId, CancellationToken ct) { // Resolve the cluster from the folder itself when the parent node did not carry it. clusterId ??= await db.RawFolders.AsNoTracking() .Where(f => f.RawFolderId == folderId).Select(f => f.ClusterId).FirstOrDefaultAsync(ct); var subFolders = await db.RawFolders.AsNoTracking() .Where(f => f.ParentRawFolderId == folderId) .OrderBy(f => f.SortOrder).ThenBy(f => f.Name) .ToListAsync(ct); var drivers = await db.DriverInstances.AsNoTracking() .Where(d => d.RawFolderId == folderId) .OrderBy(d => d.Name) .ToListAsync(ct); var nodes = new List(subFolders.Count + drivers.Count); nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, subFolders, ct)); nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct)); return nodes; } private static async Task> LoadDriverChildrenAsync( OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) { var driverId = parent.EntityId!; // The driver node carries its own DriverType; fall back to a lookup if absent. var driverType = parent.DriverType ?? await db.DriverInstances.AsNoTracking() .Where(d => d.DriverInstanceId == driverId).Select(d => d.DriverType).FirstOrDefaultAsync(ct); var devices = await db.Devices.AsNoTracking() .Where(dev => dev.DriverInstanceId == driverId) .OrderBy(dev => dev.Name) .ToListAsync(ct); var deviceIds = devices.Select(d => d.DeviceId).ToList(); var groupCounts = await CountRootGroupsByDeviceAsync(db, deviceIds, ct); var tagCounts = await CountRootTagsByDeviceAsync(db, deviceIds, ct); return devices.Select(dev => new RawNode { Kind = RawNodeKind.Device, Key = $"dev:{dev.DeviceId}", DisplayName = dev.Name, ClusterId = parent.ClusterId, EntityId = dev.DeviceId, DriverType = driverType, Enabled = dev.Enabled, HasLazyChildren = true, ChildCount = groupCounts.GetValueOrDefault(dev.DeviceId) + tagCounts.GetValueOrDefault(dev.DeviceId), RowVersion = dev.RowVersion, }).ToList(); } private static async Task> LoadDeviceChildrenAsync( OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) { var deviceId = parent.EntityId!; var groups = await db.TagGroups.AsNoTracking() .Where(g => g.DeviceId == deviceId && g.ParentTagGroupId == null) .OrderBy(g => g.SortOrder).ThenBy(g => g.Name) .ToListAsync(ct); var tags = await db.Tags.AsNoTracking() .Where(t => t.DeviceId == deviceId && t.TagGroupId == null) .OrderBy(t => t.Name) .ToListAsync(ct); var nodes = new List(groups.Count + tags.Count); nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, groups, ct)); nodes.AddRange(tags.Select(t => BuildTagNode(parent, t))); return nodes; } private static async Task> LoadTagGroupChildrenAsync( OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) { var groupId = parent.EntityId!; var subGroups = await db.TagGroups.AsNoTracking() .Where(g => g.ParentTagGroupId == groupId) .OrderBy(g => g.SortOrder).ThenBy(g => g.Name) .ToListAsync(ct); var tags = await db.Tags.AsNoTracking() .Where(t => t.TagGroupId == groupId) .OrderBy(t => t.Name) .ToListAsync(ct); var nodes = new List(subGroups.Count + tags.Count); nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, subGroups, ct)); nodes.AddRange(tags.Select(t => BuildTagNode(parent, t))); return nodes; } // --- Node builders (compute per-node child counts in bulk) --- private static async Task> BuildFolderNodesAsync( OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList folders, CancellationToken ct) { if (folders.Count == 0) return Array.Empty(); var ids = folders.Select(f => f.RawFolderId).ToList(); var subFolderCounts = (await db.RawFolders.AsNoTracking() .Where(f => f.ParentRawFolderId != null && ids.Contains(f.ParentRawFolderId)) .GroupBy(f => f.ParentRawFolderId!) .Select(g => new { Id = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); var driverCounts = (await db.DriverInstances.AsNoTracking() .Where(d => d.RawFolderId != null && ids.Contains(d.RawFolderId)) .GroupBy(d => d.RawFolderId!) .Select(g => new { Id = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); return folders.Select(f => new RawNode { Kind = RawNodeKind.Folder, Key = $"fld:{f.RawFolderId}", DisplayName = f.Name, ClusterId = clusterId, EntityId = f.RawFolderId, HasLazyChildren = true, ChildCount = subFolderCounts.GetValueOrDefault(f.RawFolderId) + driverCounts.GetValueOrDefault(f.RawFolderId), RowVersion = f.RowVersion, }).ToList(); } private static async Task> BuildDriverNodesAsync( OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList drivers, CancellationToken ct) { if (drivers.Count == 0) return Array.Empty(); var ids = drivers.Select(d => d.DriverInstanceId).ToList(); var deviceCounts = (await db.Devices.AsNoTracking() .Where(dev => ids.Contains(dev.DriverInstanceId)) .GroupBy(dev => dev.DriverInstanceId) .Select(g => new { Id = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); return drivers.Select(d => new RawNode { Kind = RawNodeKind.Driver, Key = $"drv:{d.DriverInstanceId}", DisplayName = d.Name, ClusterId = clusterId, EntityId = d.DriverInstanceId, DriverType = d.DriverType, Enabled = d.Enabled, HasLazyChildren = true, ChildCount = deviceCounts.GetValueOrDefault(d.DriverInstanceId), RowVersion = d.RowVersion, }).ToList(); } private static async Task> BuildTagGroupNodesAsync( OtOpcUaConfigDbContext db, RawNode parent, IReadOnlyList groups, CancellationToken ct) { if (groups.Count == 0) return Array.Empty(); var ids = groups.Select(g => g.TagGroupId).ToList(); var subGroupCounts = (await db.TagGroups.AsNoTracking() .Where(g => g.ParentTagGroupId != null && ids.Contains(g.ParentTagGroupId)) .GroupBy(g => g.ParentTagGroupId!) .Select(g => new { Id = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); var tagCounts = (await db.Tags.AsNoTracking() .Where(t => t.TagGroupId != null && ids.Contains(t.TagGroupId)) .GroupBy(t => t.TagGroupId!) .Select(g => new { Id = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); return groups.Select(g => new RawNode { Kind = RawNodeKind.TagGroup, Key = $"grp:{g.TagGroupId}", DisplayName = g.Name, ClusterId = parent.ClusterId, EntityId = g.TagGroupId, DriverType = parent.DriverType, HasLazyChildren = true, ChildCount = subGroupCounts.GetValueOrDefault(g.TagGroupId) + tagCounts.GetValueOrDefault(g.TagGroupId), RowVersion = g.RowVersion, }).ToList(); } private static RawNode BuildTagNode(RawNode parent, Tag t) => new() { Kind = RawNodeKind.Tag, Key = $"tag:{t.TagId}", DisplayName = t.Name, ClusterId = parent.ClusterId, EntityId = t.TagId, DriverType = parent.DriverType, HasLazyChildren = false, ChildCount = 0, RowVersion = t.RowVersion, }; private static async Task> CountRootGroupsByDeviceAsync( OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) { if (deviceIds.Count == 0) return new Dictionary(StringComparer.Ordinal); return (await db.TagGroups.AsNoTracking() .Where(g => g.ParentTagGroupId == null && deviceIds.Contains(g.DeviceId)) .GroupBy(g => g.DeviceId) .Select(g => new { Id = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); } private static async Task> CountRootTagsByDeviceAsync( OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) { if (deviceIds.Count == 0) return new Dictionary(StringComparer.Ordinal); return (await db.Tags.AsNoTracking() .Where(t => t.TagGroupId == null && deviceIds.Contains(t.DeviceId)) .GroupBy(t => t.DeviceId) .Select(g => new { Id = g.Key, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); } // --------------------------------------------------------------------------------------------- // Folder mutations // --------------------------------------------------------------------------------------------- /// public async Task CreateFolderAsync( string clusterId, string? parentFolderId, string name, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(name); if (nameError is not null) return new UnsMutationResult(false, nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct)) return new UnsMutationResult(false, $"Cluster '{clusterId}' not found."); if (parentFolderId is not null) { var parent = await db.RawFolders.AsNoTracking() .FirstOrDefaultAsync(f => f.RawFolderId == parentFolderId, ct); if (parent is null) return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' not found."); if (parent.ClusterId != clusterId) return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' is in a different cluster."); } if (await db.RawFolders.AnyAsync( f => f.ClusterId == clusterId && f.ParentRawFolderId == parentFolderId && f.Name == name, ct)) return new UnsMutationResult(false, $"A folder named '{name}' already exists here."); var id = NewId("RF"); db.RawFolders.Add(new RawFolder { RawFolderId = id, ClusterId = clusterId, ParentRawFolderId = parentFolderId, Name = name, }); return await SaveCreateAsync(db, "folder", id, ct); } /// public async Task RenameFolderAsync( string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(newName); if (nameError is not null) return RawRenameResult.Failure(nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); if (entity is null) return RawRenameResult.Failure("Row no longer exists."); if (await db.RawFolders.AnyAsync( f => f.ClusterId == entity.ClusterId && f.ParentRawFolderId == entity.ParentRawFolderId && f.Name == newName && f.RawFolderId != rawFolderId, ct)) return RawRenameResult.Failure($"A folder named '{newName}' already exists here."); var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathFolderAsync(db, rawFolderId, ct), ct); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = newName; return await SaveRenameAsync(db, warnings, "folder", ct); } /// public async Task DeleteFolderAsync( string rawFolderId, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); if (entity is null) return new UnsMutationResult(true, null); if (await db.RawFolders.AnyAsync(f => f.ParentRawFolderId == rawFolderId, ct)) return new UnsMutationResult(false, $"Cannot delete folder '{entity.Name}': it still contains sub-folders — remove or move them first."); if (await db.DriverInstances.AnyAsync(d => d.RawFolderId == rawFolderId, ct)) return new UnsMutationResult(false, $"Cannot delete folder '{entity.Name}': it still contains driver instances — remove or move them first."); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; db.RawFolders.Remove(entity); return await SaveDeleteAsync(db, "folder", ct); } /// public async Task MoveDriverToFolderAsync( string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); if (driver is null) return new UnsMutationResult(false, "Row no longer exists."); if (targetFolderId is not null) { var folder = await db.RawFolders.AsNoTracking() .FirstOrDefaultAsync(f => f.RawFolderId == targetFolderId, ct); if (folder is null) return new UnsMutationResult(false, $"Target folder '{targetFolderId}' not found."); if (folder.ClusterId != driver.ClusterId) return new UnsMutationResult(false, $"Target folder '{folder.Name}' is in cluster '{folder.ClusterId}' but the driver is in cluster '{driver.ClusterId}'."); } if (await db.DriverInstances.AnyAsync( d => d.ClusterId == driver.ClusterId && d.RawFolderId == targetFolderId && d.Name == driver.Name && d.DriverInstanceId != driverInstanceId, ct)) return new UnsMutationResult(false, $"A driver named '{driver.Name}' already exists in the target location."); db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion; driver.RawFolderId = targetFolderId; try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this driver while you were editing. Reload to see the latest values."); } } // --------------------------------------------------------------------------------------------- // Driver mutations // --------------------------------------------------------------------------------------------- /// public async Task CreateDriverAsync( string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(name); if (nameError is not null) return new UnsMutationResult(false, nameError); if (string.IsNullOrWhiteSpace(driverType)) return new UnsMutationResult(false, "Pick a driver type."); await using var db = await dbFactory.CreateDbContextAsync(ct); if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct)) return new UnsMutationResult(false, $"Cluster '{clusterId}' not found."); if (folderId is not null) { var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == folderId, ct); if (folder is null) return new UnsMutationResult(false, $"Folder '{folderId}' not found."); if (folder.ClusterId != clusterId) return new UnsMutationResult(false, $"Folder '{folderId}' is in a different cluster."); } if (await db.DriverInstances.AnyAsync( d => d.ClusterId == clusterId && d.RawFolderId == folderId && d.Name == name, ct)) return new UnsMutationResult(false, $"A driver named '{name}' already exists here."); var driverId = NewId("DRV"); db.DriverInstances.Add(new DriverInstance { DriverInstanceId = driverId, ClusterId = clusterId, RawFolderId = folderId, Name = name, DriverType = driverType, DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson, }); // Auto-create the default device so a freshly-created driver has a child to show. Wave B (WP3) // refines device/driver config authoring; here the default device carries an empty config. db.Devices.Add(new Device { DeviceId = NewId("DEV"), DriverInstanceId = driverId, Name = "Device1", DeviceConfig = "{}", }); return await SaveCreateAsync(db, "driver", driverId, ct); } /// public async Task RenameDriverAsync( string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(newName); if (nameError is not null) return RawRenameResult.Failure(nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); if (entity is null) return RawRenameResult.Failure("Row no longer exists."); if (await db.DriverInstances.AnyAsync( d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId && d.Name == newName && d.DriverInstanceId != driverInstanceId, ct)) return RawRenameResult.Failure($"A driver named '{newName}' already exists here."); var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDriverAsync(db, driverInstanceId, ct), ct); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = newName; return await SaveRenameAsync(db, warnings, "driver", ct); } /// public async Task SetDriverEnabledAsync( string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Enabled = enabled; try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this driver while you were editing."); } } /// public async Task DeleteDriverAsync( string driverInstanceId, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); if (driver is null) return new UnsMutationResult(true, null); var devices = await db.Devices.Where(dev => dev.DriverInstanceId == driverInstanceId).ToListAsync(ct); // A driver always has ≥1 device, so "has devices" cannot be the block. Instead: cascade the // (empty) auto-devices, but refuse — naming the offending device — when any device still holds // tag groups or tags, so tag authoring is never silently discarded. foreach (var dev in devices) { if (await db.TagGroups.AnyAsync(g => g.DeviceId == dev.DeviceId, ct) || await db.Tags.AnyAsync(t => t.DeviceId == dev.DeviceId, ct)) { return new UnsMutationResult(false, $"Cannot delete driver '{driver.Name}': device '{dev.Name}' still contains tag groups or tags — remove them first."); } } db.Devices.RemoveRange(devices); db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion; db.DriverInstances.Remove(driver); return await SaveDeleteAsync(db, "driver", ct); } // --------------------------------------------------------------------------------------------- // Device mutations // --------------------------------------------------------------------------------------------- /// public async Task CreateDeviceAsync( string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(name); if (nameError is not null) return new UnsMutationResult(false, nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); if (!await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == driverInstanceId, ct)) return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found."); if (await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId && dev.Name == name, ct)) return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver."); var id = NewId("DEV"); db.Devices.Add(new Device { DeviceId = id, DriverInstanceId = driverInstanceId, Name = name, DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson, }); return await SaveCreateAsync(db, "device", id, ct); } /// public async Task RenameDeviceAsync( string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(newName); if (nameError is not null) return RawRenameResult.Failure(nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); if (entity is null) return RawRenameResult.Failure("Row no longer exists."); if (await db.Devices.AnyAsync( dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == newName && dev.DeviceId != deviceId, ct)) return RawRenameResult.Failure($"A device named '{newName}' already exists on this driver."); var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDeviceAsync(db, deviceId, ct), ct); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = newName; return await SaveRenameAsync(db, warnings, "device", ct); } /// public async Task DeleteDeviceAsync( string deviceId, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); if (entity is null) return new UnsMutationResult(true, null); if (await db.TagGroups.AnyAsync(g => g.DeviceId == deviceId, ct)) return new UnsMutationResult(false, $"Cannot delete device '{entity.Name}': it still contains tag groups — remove them first."); if (await db.Tags.AnyAsync(t => t.DeviceId == deviceId, ct)) return new UnsMutationResult(false, $"Cannot delete device '{entity.Name}': it still contains tags — remove them first."); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; db.Devices.Remove(entity); return await SaveDeleteAsync(db, "device", ct); } // --------------------------------------------------------------------------------------------- // TagGroup mutations // --------------------------------------------------------------------------------------------- /// public async Task CreateTagGroupAsync( string deviceId, string? parentGroupId, string name, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(name); if (nameError is not null) return new UnsMutationResult(false, nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct)) return new UnsMutationResult(false, $"Device '{deviceId}' not found."); if (parentGroupId is not null) { var parent = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == parentGroupId, ct); if (parent is null) return new UnsMutationResult(false, $"Parent group '{parentGroupId}' not found."); if (parent.DeviceId != deviceId) return new UnsMutationResult(false, $"Parent group '{parentGroupId}' is on a different device."); } if (await db.TagGroups.AnyAsync( g => g.DeviceId == deviceId && g.ParentTagGroupId == parentGroupId && g.Name == name, ct)) return new UnsMutationResult(false, $"A tag group named '{name}' already exists here."); var id = NewId("TG"); db.TagGroups.Add(new TagGroup { TagGroupId = id, DeviceId = deviceId, ParentTagGroupId = parentGroupId, Name = name, }); return await SaveCreateAsync(db, "tag group", id, ct); } /// public async Task RenameTagGroupAsync( string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(newName); if (nameError is not null) return RawRenameResult.Failure(nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); if (entity is null) return RawRenameResult.Failure("Row no longer exists."); if (await db.TagGroups.AnyAsync( g => g.DeviceId == entity.DeviceId && g.ParentTagGroupId == entity.ParentTagGroupId && g.Name == newName && g.TagGroupId != tagGroupId, ct)) return RawRenameResult.Failure($"A tag group named '{newName}' already exists here."); var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathGroupAsync(db, tagGroupId, ct), ct); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = newName; return await SaveRenameAsync(db, warnings, "tag group", ct); } /// public async Task DeleteTagGroupAsync( string tagGroupId, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); if (entity is null) return new UnsMutationResult(true, null); if (await db.TagGroups.AnyAsync(g => g.ParentTagGroupId == tagGroupId, ct)) return new UnsMutationResult(false, $"Cannot delete tag group '{entity.Name}': it still contains sub-groups — remove them first."); if (await db.Tags.AnyAsync(t => t.TagGroupId == tagGroupId, ct)) return new UnsMutationResult(false, $"Cannot delete tag group '{entity.Name}': it still contains tags — remove them first."); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; db.TagGroups.Remove(entity); return await SaveDeleteAsync(db, "tag group", ct); } // --------------------------------------------------------------------------------------------- // Tag delete + edit projection (create/update are WP4) // --------------------------------------------------------------------------------------------- /// public async Task DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct); if (entity is null) return new UnsMutationResult(true, null); // Blocked while any UNS equipment references this raw tag — name the referencing equipment(s). var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() .Where(r => r.TagId == tagId) .Select(r => r.EquipmentId) .Distinct() .ToListAsync(ct); if (referencingEquipmentIds.Count > 0) { var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct); return new UnsMutationResult(false, $"Cannot delete tag '{entity.Name}': it is referenced by equipment {display} — remove the UNS reference(s) first."); } db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; db.Tags.Remove(entity); return await SaveDeleteAsync(db, "tag", ct); } /// public async Task LoadTagForEditAsync(string tagId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.Tags.AsNoTracking() .Where(t => t.TagId == tagId) .Select(t => new RawTagEditDto( t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel, t.WriteIdempotent, t.PollGroupId, t.TagConfig, t.RowVersion)) .FirstOrDefaultAsync(ct); } // --------------------------------------------------------------------------------------------- // Tag create / update (WP4) + CSV import (WP5) // --------------------------------------------------------------------------------------------- /// public async Task CreateTagAsync( string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(input.Name); if (nameError is not null) return new UnsMutationResult(false, nameError); var jsonError = TryNormalizeTagConfig(input.TagConfig, out var tagConfig); if (jsonError is not null) return new UnsMutationResult(false, jsonError); await using var db = await dbFactory.CreateDbContextAsync(ct); if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct)) return new UnsMutationResult(false, $"Device '{deviceId}' not found."); if (tagGroupId is not null) { var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); if (group is null) return new UnsMutationResult(false, $"Tag group '{tagGroupId}' not found."); if (group.DeviceId != deviceId) return new UnsMutationResult(false, $"Tag group '{tagGroupId}' is on a different device."); } if (await db.Tags.AnyAsync( t => t.DeviceId == deviceId && t.TagGroupId == tagGroupId && t.Name == input.Name, ct)) return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here."); var id = NewId("TAG"); db.Tags.Add(new Tag { TagId = id, DeviceId = deviceId, TagGroupId = tagGroupId, Name = input.Name, DataType = input.DataType, AccessLevel = input.AccessLevel, WriteIdempotent = input.WriteIdempotent, PollGroupId = NullIfBlank(input.PollGroupId), TagConfig = tagConfig, }); return await SaveCreateAsync(db, "tag", id, ct); } /// public async Task UpdateTagAsync( string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(input.Name); if (nameError is not null) return new UnsMutationResult(false, nameError); var jsonError = TryNormalizeTagConfig(input.TagConfig, out var tagConfig); if (jsonError is not null) return new UnsMutationResult(false, jsonError); await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct); if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); // Re-validate name uniqueness on the tag's EXISTING (DeviceId, TagGroupId), excluding self. An // identity move (changing device/group) is not this method's job — those fields are preserved. if (await db.Tags.AnyAsync( t => t.DeviceId == entity.DeviceId && t.TagGroupId == entity.TagGroupId && t.Name == input.Name && t.TagId != tagId, ct)) return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here."); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = input.Name; entity.DataType = input.DataType; entity.AccessLevel = input.AccessLevel; entity.WriteIdempotent = input.WriteIdempotent; entity.PollGroupId = NullIfBlank(input.PollGroupId); entity.TagConfig = tagConfig; try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this tag while you were editing. Reload to see the latest values."); } } /// public async Task ImportTagsAsync( string deviceId, IReadOnlyList rows, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(rows); await using var db = await dbFactory.CreateDbContextAsync(ct); if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct)) return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." }); // Existing groups on the device: a (parent-group-id | null, name) → group-id lookup we extend with // pending (auto-created) groups so a repeated path within the batch resolves to one group. var groupLookup = (await db.TagGroups.AsNoTracking() .Where(g => g.DeviceId == deviceId) .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name }) .ToListAsync(ct)) .ToDictionary(g => (g.ParentTagGroupId, g.Name), g => g.TagGroupId, GroupKeyComparer.Instance); // Existing tag identities on the device: (group-id | null, name). Extended with pending inserts so // an intra-batch duplicate is caught too. var takenNames = (await db.Tags.AsNoTracking() .Where(t => t.DeviceId == deviceId) .Select(t => new { t.TagGroupId, t.Name }) .ToListAsync(ct)) .Select(t => (t.TagGroupId, t.Name)) .ToHashSet(TagKeyComparer.Instance); var pendingGroups = new List(); var pendingTags = new List(); var errors = new List(); for (var i = 0; i < rows.Count; i++) { var row = rows[i]; var rowLabel = $"Row {i + 1}"; // Resolve (auto-creating) the tag-group path under the device. string? groupId; var groupError = ResolvePendingGroupPath(deviceId, row.TagGroupPath, groupLookup, pendingGroups, out groupId); if (groupError is not null) { errors.Add($"{rowLabel}: {groupError}"); continue; } var nameError = RawPaths.ValidateSegment(row.Tag.Name); if (nameError is not null) { errors.Add($"{rowLabel}: {nameError}"); continue; } var jsonError = TryNormalizeTagConfig(row.Tag.TagConfig, out var tagConfig); if (jsonError is not null) { errors.Add($"{rowLabel}: {jsonError}"); continue; } if (!takenNames.Add((groupId, row.Tag.Name))) { errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path."); continue; } pendingTags.Add(new Tag { TagId = NewId("TAG"), DeviceId = deviceId, TagGroupId = groupId, Name = row.Tag.Name, DataType = row.Tag.DataType, AccessLevel = row.Tag.AccessLevel, WriteIdempotent = row.Tag.WriteIdempotent, PollGroupId = NullIfBlank(row.Tag.PollGroupId), TagConfig = tagConfig, }); } // All-or-nothing: any row error inserts nothing (the review-grid pre-validates; this is the backstop). if (errors.Count > 0) return new RawTagImportResult(0, errors); db.TagGroups.AddRange(pendingGroups); db.Tags.AddRange(pendingTags); try { await db.SaveChangesAsync(ct); return new RawTagImportResult(pendingTags.Count, Array.Empty()); } catch (DbUpdateException) { // A concurrent create slipped a same-path sibling past the pre-check (the DB filtered-unique // index is the backstop). Surface an operator-friendly all-or-nothing failure. return new RawTagImportResult(0, new[] { "Import failed — a tag or group at one of these paths was created concurrently. Reload and retry." }); } } /// /// Resolves a /-separated tag-group path under a device to a group id, auto-creating any /// missing nested (added to and /// so a repeated path reuses the same group). Returns null on success /// with set (null = the device root for a blank path), or a friendly error. /// private static string? ResolvePendingGroupPath( string deviceId, string? groupPath, Dictionary<(string? Parent, string Name), string> groupLookup, List pendingGroups, out string? groupId) { groupId = null; if (string.IsNullOrWhiteSpace(groupPath)) return null; string? parent = null; foreach (var segment in groupPath.Split(RawPaths.Separator)) { var segError = RawPaths.ValidateSegment(segment); if (segError is not null) return $"tag-group path segment '{segment}': {segError}"; var key = (parent, segment); if (!groupLookup.TryGetValue(key, out var id)) { id = NewId("TG"); pendingGroups.Add(new TagGroup { TagGroupId = id, DeviceId = deviceId, ParentTagGroupId = parent, Name = segment, }); groupLookup[key] = id; } parent = id; } groupId = parent; return null; } // --------------------------------------------------------------------------------------------- // Driver config (WP3 "Configure driver") // --------------------------------------------------------------------------------------------- /// public async Task LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.DriverInstances.AsNoTracking() .Where(d => d.DriverInstanceId == driverInstanceId) .Select(d => new RawDriverEditDto( d.DriverInstanceId, d.Name, d.DriverType, d.DriverConfig, d.ResilienceConfig, d.RowVersion)) .FirstOrDefaultAsync(ct); } /// public async Task UpdateDriverAsync( string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(name); if (nameError is not null) return new UnsMutationResult(false, nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); // Sibling uniqueness in the same scope RenameDriverAsync uses (cluster + folder). if (await db.DriverInstances.AnyAsync( d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId && d.Name == name && d.DriverInstanceId != driverInstanceId, ct)) return new UnsMutationResult(false, $"A driver named '{name}' already exists here."); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = name; entity.DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson; entity.ResilienceConfig = NullIfBlank(resilienceConfig); // DriverType is immutable — not touched. try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this driver while you were editing. Reload to see the latest values."); } } // --------------------------------------------------------------------------------------------- // Device config (WP3 "Configure device") + merged probe config (Test connect) // --------------------------------------------------------------------------------------------- /// public async Task LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); // DriverType is joined in from the parent driver — the device forms dispatch the typed editor on it. return await db.Devices.AsNoTracking() .Where(dev => dev.DeviceId == deviceId) .Join(db.DriverInstances.AsNoTracking(), dev => dev.DriverInstanceId, drv => drv.DriverInstanceId, (dev, drv) => new RawDeviceEditDto( dev.DeviceId, dev.DriverInstanceId, dev.Name, dev.DeviceConfig, dev.Enabled, drv.DriverType, dev.RowVersion)) .FirstOrDefaultAsync(ct); } /// public async Task UpdateDeviceAsync( string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default) { var nameError = RawPaths.ValidateSegment(name); if (nameError is not null) return new UnsMutationResult(false, nameError); await using var db = await dbFactory.CreateDbContextAsync(ct); var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); if (await db.Devices.AnyAsync( dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == name && dev.DeviceId != deviceId, ct)) return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver."); db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = name; entity.DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson; entity.Enabled = enabled; try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, "Another user changed this device while you were editing. Reload to see the latest values."); } } /// public async Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync( string deviceId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var device = await db.Devices.AsNoTracking().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); if (device is null) return null; var driver = await db.DriverInstances.AsNoTracking() .FirstOrDefaultAsync(d => d.DriverInstanceId == device.DriverInstanceId, ct); if (driver is null) return null; // Endpoint now lives in DeviceConfig — merge it up (single device ⇒ DeviceConfig keys win) into the // one config blob the IDriverProbe path binds from. No tags feed a connect probe. var merged = DriverDeviceConfigMerger.Merge( driver.DriverConfig, new[] { new DriverDeviceConfigMerger.DeviceRow(device.Name, device.DeviceConfig) }, Array.Empty()); return (driver.DriverType, merged); } // --------------------------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------------------------- /// Generates a system id of the form {prefix}-{12 hex} (≤ the 64-char id limit). private static string NewId(string prefix) => $"{prefix}-{Guid.NewGuid().ToString("N")[..12]}"; /// Collapses a whitespace-only optional string to null. private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; /// /// Validates a TagConfig blob for JSON well-formedness (blank ⇒ {}). Returns a friendly /// error when the blob is not parseable JSON; otherwise null with set. /// private static string? TryNormalizeTagConfig(string? tagConfig, out string normalized) { normalized = string.IsNullOrWhiteSpace(tagConfig) ? "{}" : tagConfig; try { using var _ = JsonDocument.Parse(normalized); return null; } catch (JsonException) { return "TagConfig is not well-formed JSON."; } } /// Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key. private sealed class GroupKeyComparer : IEqualityComparer<(string? Parent, string Name)> { public static readonly GroupKeyComparer Instance = new(); public bool Equals((string? Parent, string Name) x, (string? Parent, string Name) y) => string.Equals(x.Parent, y.Parent, StringComparison.Ordinal) && string.Equals(x.Name, y.Name, StringComparison.Ordinal); public int GetHashCode((string? Parent, string Name) obj) => HashCode.Combine( obj.Parent is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Parent), StringComparer.Ordinal.GetHashCode(obj.Name)); } /// Ordinal (case-sensitive) equality for a nullable-group + name tag identity key. private sealed class TagKeyComparer : IEqualityComparer<(string? GroupId, string Name)> { public static readonly TagKeyComparer Instance = new(); public bool Equals((string? GroupId, string Name) x, (string? GroupId, string Name) y) => string.Equals(x.GroupId, y.GroupId, StringComparison.Ordinal) && string.Equals(x.Name, y.Name, StringComparison.Ordinal); public int GetHashCode((string? GroupId, string Name) obj) => HashCode.Combine( obj.GroupId is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.GroupId), StringComparer.Ordinal.GetHashCode(obj.Name)); } /// Commits a rename, translating a concurrency clash into a friendly failure. private static async Task SaveRenameAsync( OtOpcUaConfigDbContext db, IReadOnlyList warnings, string noun, CancellationToken ct) { try { await db.SaveChangesAsync(ct); return RawRenameResult.Success(warnings); } catch (DbUpdateConcurrencyException) { return RawRenameResult.Failure($"Another user changed this {noun} while you were editing. Reload to see the latest values."); } } /// Commits a delete, translating a concurrency clash / stray FK block into a friendly failure. private static async Task SaveDeleteAsync( OtOpcUaConfigDbContext db, string noun, CancellationToken ct) { try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null); } catch (DbUpdateConcurrencyException) { return new UnsMutationResult(false, $"Another user changed this {noun} while you were viewing it."); } catch (Exception) { // Defensive net for a real SQL Server where a child slipped past the pre-check (race). // Don't surface the raw EF/SQL text (leaks schema detail); give operator-friendly guidance. return new UnsMutationResult(false, $"Delete failed — a related record still references this {noun}. Reload and retry."); } } /// Commits a create, translating a lost sibling-name race (filtered-unique-index clash) into a friendly failure. private static async Task SaveCreateAsync( OtOpcUaConfigDbContext db, string noun, string createdId, CancellationToken ct) { try { await db.SaveChangesAsync(ct); return new UnsMutationResult(true, null, createdId); } catch (DbUpdateException) { // The name-uniqueness pre-check narrows but doesn't close the race against a concurrent // create of the same-name sibling; the DB's filtered-unique index is the backstop. return new UnsMutationResult(false, $"A {noun} with that name already exists here (created concurrently). Reload and retry."); } } /// /// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and /// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname / /// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here. /// private static async Task> BuildRenameWarningsAsync( OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct) { var warnings = new List(); if (tags.Count == 0) return warnings; var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized); if (historized > 0) { warnings.Add(historized == 1 ? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path." : $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths."); } var tagIds = tags.Select(t => t.TagId).ToList(); var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() .Where(r => tagIds.Contains(r.TagId)) .Select(r => r.EquipmentId) .Distinct() .ToListAsync(ct); if (referencingEquipmentIds.Count > 0) { var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct); warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename."); } // Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append // a "N script(s) reference tags beneath this node" warning to this same list. return warnings; } /// Renders a friendly "Name (Id), …" list for the referencing equipment ids. private static async Task DescribeEquipmentAsync( OtOpcUaConfigDbContext db, IReadOnlyList equipmentIds, CancellationToken ct) { var names = (await db.Equipment.AsNoTracking() .Where(e => equipmentIds.Contains(e.EquipmentId)) .Select(e => new { e.EquipmentId, e.Name }) .ToListAsync(ct)) .ToDictionary(x => x.EquipmentId, x => x.Name, StringComparer.Ordinal); return string.Join(", ", equipmentIds .OrderBy(id => id, StringComparer.Ordinal) .Select(id => names.TryGetValue(id, out var n) ? $"'{n}' ({id})" : $"'{id}'")); } // --- Subtree tag collectors (walk the in-DB subtree for rename impact) --- private static async Task> CollectTagsBeneathFolderAsync( OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct) { var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); if (folder is null) return Array.Empty<(string, string)>(); // All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId. var clusterFolders = await db.RawFolders.AsNoTracking() .Where(f => f.ClusterId == folder.ClusterId) .Select(f => new { f.RawFolderId, f.ParentRawFolderId }) .ToListAsync(ct); var childrenByParent = clusterFolders .Where(f => f.ParentRawFolderId is not null) .GroupBy(f => f.ParentRawFolderId!, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.Select(x => x.RawFolderId).ToList(), StringComparer.Ordinal); var folderIds = DescendantsInclusive(rawFolderId, childrenByParent); var driverIds = await db.DriverInstances.AsNoTracking() .Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId)) .Select(d => d.DriverInstanceId) .ToListAsync(ct); if (driverIds.Count == 0) return Array.Empty<(string, string)>(); var deviceIds = await db.Devices.AsNoTracking() .Where(dev => driverIds.Contains(dev.DriverInstanceId)) .Select(dev => dev.DeviceId) .ToListAsync(ct); if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); return await TagsForDevicesAsync(db, deviceIds, ct); } private static async Task> CollectTagsBeneathDriverAsync( OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct) { var deviceIds = await db.Devices.AsNoTracking() .Where(dev => dev.DriverInstanceId == driverInstanceId) .Select(dev => dev.DeviceId) .ToListAsync(ct); if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); return await TagsForDevicesAsync(db, deviceIds, ct); } private static async Task> CollectTagsBeneathDeviceAsync( OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct) { return (await db.Tags.AsNoTracking() .Where(t => t.DeviceId == deviceId) .Select(t => new { t.TagId, t.TagConfig }) .ToListAsync(ct)) .Select(t => (t.TagId, t.TagConfig)) .ToList(); } private static async Task> CollectTagsBeneathGroupAsync( OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct) { var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); if (group is null) return Array.Empty<(string, string)>(); var deviceGroups = await db.TagGroups.AsNoTracking() .Where(g => g.DeviceId == group.DeviceId) .Select(g => new { g.TagGroupId, g.ParentTagGroupId }) .ToListAsync(ct); var childrenByParent = deviceGroups .Where(g => g.ParentTagGroupId is not null) .GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal); var groupIds = DescendantsInclusive(tagGroupId, childrenByParent); return (await db.Tags.AsNoTracking() .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId)) .Select(t => new { t.TagId, t.TagConfig }) .ToListAsync(ct)) .Select(t => (t.TagId, t.TagConfig)) .ToList(); } private static async Task> TagsForDevicesAsync( OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) { return (await db.Tags.AsNoTracking() .Where(t => deviceIds.Contains(t.DeviceId)) .Select(t => new { t.TagId, t.TagConfig }) .ToListAsync(ct)) .Select(t => (t.TagId, t.TagConfig)) .ToList(); } /// /// Returns plus all its transitive descendants, using a /// parent→children adjacency map. Iterative (stack-based) so deep trees can't blow the call stack. /// private static HashSet DescendantsInclusive( string rootId, IReadOnlyDictionary> childrenByParent) { var result = new HashSet(StringComparer.Ordinal); var stack = new Stack(); stack.Push(rootId); while (stack.Count > 0) { var id = stack.Pop(); if (!result.Add(id)) continue; // guard against cycles / double-visits if (childrenByParent.TryGetValue(id, out var kids)) { foreach (var kid in kids) stack.Push(kid); } } return result; } }