feat(auditlog): multi-value AuditLogQueryFilter dimensions

This commit is contained in:
Joseph Doherty
2026-05-21 05:15:51 -04:00
parent b3b02a8cb6
commit 37c7a0e5ac
15 changed files with 196 additions and 77 deletions

View File

@@ -116,10 +116,10 @@ public static class AuditExportEndpoints
DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: site,
Channels: channel is { } c ? new[] { c } : null,
Kinds: kind is { } k ? new[] { k } : null,
Statuses: status is { } s ? new[] { s } : null,
SourceSiteIds: site is { } siteId ? new[] { siteId } : null,
Target: target,
Actor: actor,
CorrelationId: correlationId,

View File

@@ -114,10 +114,10 @@ public sealed class AuditQueryModel
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
return new AuditLogQueryFilter(
Channel: Channels.Count > 0 ? Channels.First() : null,
Kind: Kinds.Count > 0 ? Kinds.First() : null,
Status: status,
SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null,
Channels: Channels.Count > 0 ? new[] { Channels.First() } : null,
Kinds: Kinds.Count > 0 ? new[] { Kinds.First() } : null,
Statuses: status is { } s ? new[] { s } : null,
SourceSiteIds: SiteIdentifiers.Count > 0 ? new[] { SiteIdentifiers.First() } : null,
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,

View File

@@ -129,9 +129,9 @@ public partial class AuditLogPage
}
_currentFilter = new AuditLogQueryFilter(
Channel: channel,
Status: status,
SourceSiteId: site,
Channels: channel is { } ch ? new[] { ch } : null,
Statuses: status is { } st ? new[] { st } : null,
SourceSiteIds: site is { } siteId ? new[] { siteId } : null,
Target: target,
Actor: actor,
CorrelationId: correlationId);
@@ -181,21 +181,24 @@ public partial class AuditLogPage
}
var parts = new List<KeyValuePair<string, string?>>(9);
if (filter.Channel is { } ch)
// Task 8: the filter dimensions are multi-value now; the export query-string
// is still single-value, so emit the first selected value of each list.
// Task 9 widens the query-string contract to carry the full set.
if (filter.Channels is { Count: > 0 } channels)
{
parts.Add(new("channel", ch.ToString()));
parts.Add(new("channel", channels[0].ToString()));
}
if (filter.Kind is { } kind)
if (filter.Kinds is { Count: > 0 } kinds)
{
parts.Add(new("kind", kind.ToString()));
parts.Add(new("kind", kinds[0].ToString()));
}
if (filter.Status is { } status)
if (filter.Statuses is { Count: > 0 } statuses)
{
parts.Add(new("status", status.ToString()));
parts.Add(new("status", statuses[0].ToString()));
}
if (!string.IsNullOrWhiteSpace(filter.SourceSiteId))
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds && !string.IsNullOrWhiteSpace(sourceSiteIds[0]))
{
parts.Add(new("site", filter.SourceSiteId));
parts.Add(new("site", sourceSiteIds[0]));
}
if (!string.IsNullOrWhiteSpace(filter.Target))
{

View File

@@ -4,16 +4,20 @@ namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
/// <c>&gt;=</c> / <c>&lt;=</c> respectively. All filter fields are AND-combined.
/// Any field left <c>null</c> means "do not constrain on that column". The
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
/// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
/// list means "do not constrain", and a non-empty list is OR-combined within the
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another.
/// </summary>
public sealed record AuditLogQueryFilter(
AuditChannel? Channel = null,
AuditKind? Kind = null,
AuditStatus? Status = null,
string? SourceSiteId = null,
IReadOnlyList<AuditChannel>? Channels = null,
IReadOnlyList<AuditKind>? Kinds = null,
IReadOnlyList<AuditStatus>? Statuses = null,
IReadOnlyList<string>? SourceSiteIds = null,
string? Target = null,
string? Actor = null,
Guid? CorrelationId = null,

View File

@@ -116,25 +116,28 @@ VALUES
var query = _context.Set<AuditEvent>().AsNoTracking();
if (filter.Channel is { } channel)
// Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
// WHERE 1=0). A non-empty list translates to a SQL IN (…) via EF Core's
// IReadOnlyList<T>.Contains support — server-side, no client-eval.
if (filter.Channels is { Count: > 0 } channels)
{
query = query.Where(e => e.Channel == channel);
query = query.Where(e => channels.Contains(e.Channel));
}
if (filter.Kind is { } kind)
if (filter.Kinds is { Count: > 0 } kinds)
{
query = query.Where(e => e.Kind == kind);
query = query.Where(e => kinds.Contains(e.Kind));
}
if (filter.Status is { } status)
if (filter.Statuses is { Count: > 0 } statuses)
{
query = query.Where(e => e.Status == status);
query = query.Where(e => statuses.Contains(e.Status));
}
if (!string.IsNullOrEmpty(filter.SourceSiteId))
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{
var siteId = filter.SourceSiteId;
query = query.Where(e => e.SourceSiteId == siteId);
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
}
if (!string.IsNullOrEmpty(filter.Target))

View File

@@ -401,11 +401,13 @@ public static class AuditEndpoints
correlationId = parsedCorr;
}
var sourceSiteId = TrimToNullable(query, "sourceSiteId");
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: TrimToNullable(query, "sourceSiteId"),
Channels: channel is { } c ? new[] { c } : null,
Kinds: kind is { } k ? new[] { k } : null,
Statuses: status is { } s ? new[] { s } : null,
SourceSiteIds: sourceSiteId is { } site ? new[] { site } : null,
Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId,