feat(auditlog): multi-value AuditLogQueryFilter dimensions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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>>=</c> / <c><=</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>>=</c> / <c><=</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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user