perf(adminui): rename script scan filters server-side, stops shipping every script body

This commit is contained in:
Joseph Doherty
2026-08-16 04:24:10 -04:00
parent a76b4e0740
commit fe8d132c64
@@ -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 =&gt; 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