fix(adminui): dedup + chunk the rename script-scan OR-chain
This commit is contained in:
@@ -1415,6 +1415,12 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Max <c>OR</c> terms in one script-scan predicate — bounds both EF's recursive expression-visitor
|
||||
/// depth over the left-nested <c>OrElse</c> chain and SQL Server's boolean-expression complexity limit.
|
||||
/// </summary>
|
||||
private const int ScriptScanBatchSize = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Scans every <see cref="Script"/> body for the OLD RawPath of any affected tag as a plain substring
|
||||
/// (no AST — false positives tolerated by design; <c>{{equip}}</c>-relative refs are NOT scanned, they
|
||||
@@ -1423,7 +1429,9 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
/// <para>
|
||||
/// The substring match runs in the DATABASE (see <see cref="AnyPathContained"/>), so unmatched script
|
||||
/// bodies — the overwhelming majority, and the largest text column in the config DB — never cross the
|
||||
/// wire; only the matched scripts' names are projected.
|
||||
/// wire; only the matched scripts' names are projected. The paths are deduplicated and scanned in
|
||||
/// batches of <see cref="ScriptScanBatchSize"/>, so the disjunction stays bounded whatever the size of
|
||||
/// the renamed subtree.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static async Task<string?> BuildScriptReferenceWarningAsync(
|
||||
@@ -1432,16 +1440,30 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct);
|
||||
if (oldPaths.Count == 0) return null;
|
||||
|
||||
var matched = await db.Scripts.AsNoTracking()
|
||||
.Where(AnyPathContained(oldPaths))
|
||||
.Select(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
// One path per affected TAG, so an ancestor rename yields thousands of them, many identical (a
|
||||
// tag name repeated under sibling devices). Deduplicating first shortens the disjunction without
|
||||
// narrowing the match — the SQL comparison runs under the column collation either way.
|
||||
var distinctPaths = oldPaths.Distinct(StringComparer.Ordinal).ToArray();
|
||||
|
||||
// ScriptId, not Name: Name carries no unique index, and collapsing two same-named scripts into one
|
||||
// would change the count this warning reports.
|
||||
var matched = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
for (var offset = 0; offset < distinctPaths.Length; offset += ScriptScanBatchSize)
|
||||
{
|
||||
var batch = distinctPaths[offset..Math.Min(offset + ScriptScanBatchSize, distinctPaths.Length)];
|
||||
var rows = await db.Scripts.AsNoTracking()
|
||||
.Where(AnyPathContained(batch))
|
||||
.Select(s => new { s.ScriptId, s.Name })
|
||||
.ToListAsync(ct);
|
||||
foreach (var row in rows) matched[row.ScriptId] = row.Name;
|
||||
}
|
||||
if (matched.Count == 0) return null;
|
||||
|
||||
// Ordered in memory: SQL's ORDER BY would sort under the column collation, not ordinally.
|
||||
matched.Sort(StringComparer.Ordinal);
|
||||
var matchedNames = matched.Values.ToList();
|
||||
matchedNames.Sort(StringComparer.Ordinal);
|
||||
|
||||
var names = string.Join(", ", matched.Select(n => $"'{n}'"));
|
||||
var names = string.Join(", ", matchedNames.Select(n => $"'{n}'"));
|
||||
return matched.Count == 1
|
||||
? $"1 script ({names}) references a tag beneath this node by its raw path; the reference will break unless the script is updated."
|
||||
: $"{matched.Count} scripts ({names}) reference tags beneath this node by their raw paths; the references will break unless the scripts are updated.";
|
||||
|
||||
Reference in New Issue
Block a user