From fe8d132c642154c00e67d511b035373c1af48ef9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 16 Aug 2026 04:24:10 -0400 Subject: [PATCH] perf(adminui): rename script scan filters server-side, stops shipping every script body --- .../Uns/RawTreeService.cs | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 17fe3e8d..07978e9a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -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 dbF } /// - /// Scans every body for the OLD RawPath of any affected tag as a plain ordinal - /// substring (no AST — false positives tolerated by design; {{equip}}-relative refs are NOT - /// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning - /// naming the matched scripts, or when none match. + /// Scans every body for the OLD RawPath of any affected tag as a plain substring + /// (no AST — false positives tolerated by design; {{equip}}-relative refs are NOT scanned, they + /// resolve through references and the deploy gate catches breakage). Returns a warning naming the + /// matched scripts, or when none match. + /// + /// The substring match runs in the DATABASE (see ), 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. + /// /// private static async Task BuildScriptReferenceWarningAsync( OtOpcUaConfigDbContext db, IReadOnlyList tags, CancellationToken ct) @@ -1426,23 +1432,57 @@ public sealed class RawTreeService(IDbContextFactory 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."; } + /// + /// Builds s => s.SourceCode.Contains(p0) || s.SourceCode.Contains(p1) || … over + /// — one disjunction sharing a single parameter expression, so it composes + /// into an EF Where and the scan runs server-side (SQL Server: CHARINDEX; the EF InMemory + /// provider: the same in-memory the caller used to run itself). + /// Callers must guarantee is non-empty (a disjunction of nothing has no + /// meaningful identity here — the caller's degenerate guard returns before this point). + /// + /// + /// ⚠️ Match semantics widen slightly on SQL Server: CHARINDEX runs under the column's collation, + /// which is case-insensitive on a default install, where the previous in-memory scan was + /// . 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. + /// + private static Expression> AnyPathContained(IReadOnlyList 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>(body!, parameter); + } + /// /// 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