perf(adminui): rename script scan filters server-side, stops shipping every script body
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
@@ -1415,10 +1416,15 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans every <see cref="Script"/> body for the OLD RawPath of any affected tag as a plain ordinal
|
||||
/// substring (no AST — false positives tolerated by design; <c>{{equip}}</c>-relative refs are NOT
|
||||
/// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning
|
||||
/// naming the matched scripts, or <see langword="null"/> when none match.
|
||||
/// 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
|
||||
/// resolve through references and the deploy gate catches breakage). Returns a warning naming the
|
||||
/// matched scripts, or <see langword="null"/> when none match.
|
||||
/// <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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static async Task<string?> BuildScriptReferenceWarningAsync(
|
||||
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
|
||||
@@ -1426,23 +1432,57 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct);
|
||||
if (oldPaths.Count == 0) return null;
|
||||
|
||||
var scripts = await db.Scripts.AsNoTracking()
|
||||
.Select(s => new { s.ScriptId, s.Name, s.SourceCode })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var matched = scripts
|
||||
.Where(s => oldPaths.Any(p => s.SourceCode.Contains(p, StringComparison.Ordinal)))
|
||||
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
||||
var matched = await db.Scripts.AsNoTracking()
|
||||
.Where(AnyPathContained(oldPaths))
|
||||
.Select(s => s.Name)
|
||||
.ToList();
|
||||
.ToListAsync(ct);
|
||||
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 names = string.Join(", ", matched.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.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <c>s => s.SourceCode.Contains(p0) || s.SourceCode.Contains(p1) || …</c> over
|
||||
/// <paramref name="paths"/> — one disjunction sharing a single parameter expression, so it composes
|
||||
/// into an EF <c>Where</c> and the scan runs server-side (SQL Server: <c>CHARINDEX</c>; the EF InMemory
|
||||
/// provider: the same in-memory <see cref="string.Contains(string)"/> the caller used to run itself).
|
||||
/// Callers must guarantee <paramref name="paths"/> is non-empty (a disjunction of nothing has no
|
||||
/// meaningful identity here — the caller's degenerate guard returns before this point).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Match semantics widen slightly on SQL Server: <c>CHARINDEX</c> runs under the column's collation,
|
||||
/// which is case-insensitive on a default install, where the previous in-memory scan was
|
||||
/// <see cref="StringComparison.Ordinal"/>. Accepted deliberately — RawPaths are operator-authored
|
||||
/// identifiers, this warning is advisory, and a false positive costs an operator one extra glance
|
||||
/// while a false negative (the risk the old scan actually carried, since a script may name a path in
|
||||
/// any casing) silently ships a broken rename.
|
||||
/// </remarks>
|
||||
private static Expression<Func<Script, bool>> AnyPathContained(IReadOnlyList<string> paths)
|
||||
{
|
||||
var parameter = Expression.Parameter(typeof(Script), "s");
|
||||
var sourceCode = Expression.Property(parameter, nameof(Script.SourceCode));
|
||||
var contains = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!;
|
||||
|
||||
// Indexing a captured array (rather than Expression.Constant(path)) keeps EF parameterising each
|
||||
// path instead of inlining it as a SQL literal, so the paths never reach the generated SQL text.
|
||||
var values = paths.ToArray();
|
||||
Expression? body = null;
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
var value = Expression.ArrayIndex(Expression.Constant(values), Expression.Constant(i));
|
||||
var call = Expression.Call(sourceCode, contains, value);
|
||||
body = body is null ? call : Expression.OrElse(body, call);
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<Script, bool>>(body!, parameter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the OLD RawPath of each affected tag from the current (pre-save) in-DB raw topology,
|
||||
/// dropping any tag whose ancestry chain is broken (a null RawPath). Loads only the topology the affected
|
||||
|
||||
Reference in New Issue
Block a user