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;
}
}