perf(search): prefix-by-default Deployments and config-audit search; bounded Contains opt-in (ScadaBridge#50)
Deployments free-text search and the config-audit EntityName filter no longer emit LIKE '%term%' by default. Prefix (LIKE 'term%') is the default; substring is an explicit opt-in (DeploymentSearchMode.Contains / entityNameContains) always bounded by a DeployedAt/Timestamp window (explicit, else 30 days). Migration PrefixSearchIndexes adds DeployedBy, RevisionHash and EntityName indexes. Deployments page gains a 'Match anywhere' checkbox. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+1
-1
@@ -258,7 +258,7 @@ public class DeploymentListQueryRepositoryTests : IDisposable
|
||||
/// <param name="expected">The expected.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("Filler", "dep-a")] // instance unique name
|
||||
[InlineData("Line3", "dep-a")] // instance unique name (prefix)
|
||||
[InlineData("dep-b", "dep-b")] // deployment id
|
||||
[InlineData("carol", "dep-b")] // initiating user
|
||||
[InlineData("rev-a", "dep-a")] // revision hash
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#50: the Deployments and config-audit free-text searches must not use a
|
||||
/// leading-wildcard LIKE by default. Query-shape tests render the SQL Server provider's SQL
|
||||
/// (no connection needed); result tests run on SQLite.
|
||||
/// </summary>
|
||||
public class SearchPrefixQueryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context = SqliteTestHelper.CreateInMemoryContext();
|
||||
private readonly DeploymentManagerRepository _deployments;
|
||||
private readonly CentralUiRepository _audit;
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>SearchPrefixQueryTests</c> class.</summary>
|
||||
public SearchPrefixQueryTests()
|
||||
{
|
||||
_deployments = new DeploymentManagerRepository(_context);
|
||||
_audit = new CentralUiRepository(_context);
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by the test fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static ScadaBridgeDbContext SqlServerContext() =>
|
||||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer("Server=unused;Database=unused;")
|
||||
.Options);
|
||||
|
||||
// ── Query shape (SQL Server SQL) ─────────────────────────────────────────
|
||||
|
||||
/// <summary>Default deployment search renders a trailing-wildcard-only LIKE and no time window.</summary>
|
||||
[Fact]
|
||||
public void Deployments_DefaultSearch_HasNoLeadingWildcard()
|
||||
{
|
||||
using var ctx = SqlServerContext();
|
||||
var sql = new DeploymentManagerRepository(ctx)
|
||||
.BuildListQuery(new DeploymentListFilter(Search: "Line3"), applyStatus: true)
|
||||
.ToQueryString();
|
||||
|
||||
// EF parameterises the LIKE pattern: assert on the declared pattern values.
|
||||
Assert.Contains("LIKE @search_startswith ESCAPE", sql);
|
||||
Assert.Contains("= N'Line3%';", sql);
|
||||
Assert.DoesNotContain("= N'%", sql);
|
||||
Assert.DoesNotContain("_contains", sql);
|
||||
Assert.DoesNotContain("DeployedAt] >=", sql);
|
||||
}
|
||||
|
||||
/// <summary>The Contains opt-in keeps the substring scan but is always time-bounded.</summary>
|
||||
[Fact]
|
||||
public void Deployments_ContainsSearch_HasLeadingWildcard_AndIsTimeBounded()
|
||||
{
|
||||
using var ctx = SqlServerContext();
|
||||
var sql = new DeploymentManagerRepository(ctx)
|
||||
.BuildListQuery(new DeploymentListFilter(Search: "Filler", SearchMode: DeploymentSearchMode.Contains), applyStatus: true)
|
||||
.ToQueryString();
|
||||
|
||||
Assert.Contains("= N'%Filler%';", sql);
|
||||
Assert.Contains("_contains", sql);
|
||||
Assert.Contains("DeployedAt] >=", sql);
|
||||
}
|
||||
|
||||
/// <summary>Default audit entity-name filter is a prefix match with no window.</summary>
|
||||
[Fact]
|
||||
public void AuditLog_DefaultEntityName_HasNoLeadingWildcard_NoWindow()
|
||||
{
|
||||
using var ctx = SqlServerContext();
|
||||
var sql = new CentralUiRepository(ctx)
|
||||
.BuildAuditLogQuery(null, null, null, null, null, null, "Pump", null, false, DateTimeOffset.UtcNow)
|
||||
.ToQueryString();
|
||||
|
||||
Assert.Contains("LIKE @entityName_startswith ESCAPE", sql);
|
||||
Assert.Contains("= N'Pump%';", sql);
|
||||
Assert.DoesNotContain("= N'%", sql);
|
||||
Assert.DoesNotContain("[Timestamp] >=", sql);
|
||||
}
|
||||
|
||||
/// <summary>The audit Contains opt-in is bounded by the default window.</summary>
|
||||
[Fact]
|
||||
public void AuditLog_ContainsEntityName_IsBoundedByDefaultWindow()
|
||||
{
|
||||
using var ctx = SqlServerContext();
|
||||
var sql = new CentralUiRepository(ctx)
|
||||
.BuildAuditLogQuery(null, null, null, null, null, null, "Pump", null, true, DateTimeOffset.UtcNow)
|
||||
.ToQueryString();
|
||||
|
||||
Assert.Contains("= N'%Pump%';", sql);
|
||||
Assert.Contains("[Timestamp] >=", sql);
|
||||
}
|
||||
|
||||
/// <summary>The effective window applies only to Contains searches or an explicit bound.</summary>
|
||||
[Fact]
|
||||
public void EffectiveDeployedFrom_OnlyForContains_OrExplicit()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 11, 9, 0, 0, 0, TimeSpan.Zero);
|
||||
Assert.Null(new DeploymentListFilter(Search: "x").EffectiveDeployedFrom(now));
|
||||
Assert.Null(new DeploymentListFilter(SearchMode: DeploymentSearchMode.Contains).EffectiveDeployedFrom(now));
|
||||
Assert.Equal(now.AddDays(-30),
|
||||
new DeploymentListFilter(Search: "x", SearchMode: DeploymentSearchMode.Contains).EffectiveDeployedFrom(now));
|
||||
var explicitFrom = now.AddDays(-2);
|
||||
Assert.Equal(explicitFrom,
|
||||
new DeploymentListFilter(Search: "x", SearchMode: DeploymentSearchMode.Contains, DeployedFrom: explicitFrom).EffectiveDeployedFrom(now));
|
||||
}
|
||||
|
||||
// ── Results (SQLite) ─────────────────────────────────────────────────────
|
||||
|
||||
private async Task SeedDeploymentsAsync()
|
||||
{
|
||||
var site = new Site("S1", "S1");
|
||||
_context.Sites.Add(site);
|
||||
var template = new Template("T");
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
var a = new Instance("Line3.Filler") { SiteId = site.Id, TemplateId = template.Id };
|
||||
var b = new Instance("Line4.Filler") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.AddRange(a, b);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.DeploymentRecords.AddRange(
|
||||
new DeploymentRecord("dep-old", "alice") { InstanceId = a.Id, Status = DeploymentStatus.Success, DeployedAt = DateTimeOffset.UtcNow.AddDays(-90) },
|
||||
new DeploymentRecord("dep-new", "alice") { InstanceId = b.Id, Status = DeploymentStatus.Success, DeployedAt = DateTimeOffset.UtcNow.AddDays(-1) });
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Prefix search does not match mid-string, is case-insensitive, finds the prefix.</summary>
|
||||
[Fact]
|
||||
public async Task Deployments_Prefix_DoesNotMatchInTheMiddle()
|
||||
{
|
||||
await SeedDeploymentsAsync();
|
||||
|
||||
var mid = await _deployments.QueryDeploymentListPageAsync(new DeploymentListFilter(Search: "Filler"), 1, 25);
|
||||
Assert.Equal(0, mid.TotalCount);
|
||||
|
||||
var prefix = await _deployments.QueryDeploymentListPageAsync(new DeploymentListFilter(Search: "line3"), 1, 25);
|
||||
Assert.Equal("dep-old", Assert.Single(prefix.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
/// <summary>Contains matches mid-string but only inside the window; counts agree.</summary>
|
||||
[Fact]
|
||||
public async Task Deployments_Contains_MatchesInTheMiddle_ButOnlyWithinTheWindow()
|
||||
{
|
||||
await SeedDeploymentsAsync();
|
||||
|
||||
var page = await _deployments.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Search: "Filler", SearchMode: DeploymentSearchMode.Contains), 1, 25);
|
||||
Assert.Equal("dep-new", Assert.Single(page.Rows).DeploymentId);
|
||||
|
||||
var wide = await _deployments.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Search: "Filler", SearchMode: DeploymentSearchMode.Contains,
|
||||
DeployedFrom: DateTimeOffset.UtcNow.AddDays(-365)), 1, 25);
|
||||
Assert.Equal(2, wide.TotalCount);
|
||||
|
||||
var counts = await _deployments.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(Search: "Filler", SearchMode: DeploymentSearchMode.Contains));
|
||||
Assert.Equal(1, counts.Total);
|
||||
}
|
||||
|
||||
/// <summary>A literal percent is not a wildcard in prefix mode.</summary>
|
||||
[Fact]
|
||||
public async Task Deployments_Prefix_TreatsPercentAsLiteral()
|
||||
{
|
||||
await SeedDeploymentsAsync();
|
||||
var page = await _deployments.QueryDeploymentListPageAsync(new DeploymentListFilter(Search: "%"), 1, 25);
|
||||
Assert.Equal(0, page.TotalCount);
|
||||
}
|
||||
|
||||
/// <summary>Audit entity-name: prefix by default, bounded Contains on opt-in.</summary>
|
||||
[Fact]
|
||||
public async Task AuditLog_EntityName_PrefixByDefault_ContainsOptInWithinWindow()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("a", "Create", "Template", "1", "PumpStation") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("a", "Create", "Template", "2", "BigPump") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("a", "Create", "Template", "3", "OldPump") { Timestamp = DateTimeOffset.UtcNow.AddDays(-90) });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (prefix, _) = await _audit.GetAuditLogEntriesAsync(entityName: "pump");
|
||||
Assert.Equal("PumpStation", Assert.Single(prefix).EntityName);
|
||||
|
||||
var (contains, _) = await _audit.GetAuditLogEntriesAsync(entityName: "Pump", entityNameContains: true);
|
||||
Assert.Equal(new[] { "BigPump", "PumpStation" }, contains.Select(e => e.EntityName).OrderBy(n => n));
|
||||
|
||||
var (wide, _) = await _audit.GetAuditLogEntriesAsync(
|
||||
entityName: "Pump", entityNameContains: true, from: DateTimeOffset.UtcNow.AddDays(-365));
|
||||
Assert.Equal(3, wide.Count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user