using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ScadaLink.Commons.Messages.RemoteQuery; namespace ScadaLink.SiteEventLogging; /// /// Processes event log queries locally against SQLite. /// Supports filtering by event_type, time range, instance_id, severity, /// and keyword search (LIKE on message and source). /// Uses keyset pagination with continuation token (last event ID). /// public class EventLogQueryService : IEventLogQueryService { private readonly SiteEventLogger _eventLogger; private readonly SiteEventLogOptions _options; private readonly ILogger _logger; public EventLogQueryService( SiteEventLogger eventLogger, IOptions options, ILogger logger) { // Depend on the concrete recorder directly: queries must funnel database // access through its lock-guarded WithConnection. Taking ISiteEventLogger and // downcasting would throw InvalidCastException for any other implementation. _eventLogger = eventLogger; _options = options.Value; _logger = logger; } /// /// Escapes the SQL LIKE metacharacters (\, %, _) in a /// user-supplied keyword so it is matched as a literal substring. Used together /// with a LIKE ... ESCAPE '\' clause. /// private static string EscapeLikePattern(string input) { return input .Replace("\\", "\\\\") .Replace("%", "\\%") .Replace("_", "\\_"); } public EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request) { try { var pageSize = request.PageSize > 0 ? request.PageSize : _options.QueryPageSize; var whereClauses = new List(); var parameters = new List(); // Keyset pagination: only return events with id > continuation token if (request.ContinuationToken.HasValue) { whereClauses.Add("id > $afterId"); parameters.Add(new SqliteParameter("$afterId", request.ContinuationToken.Value)); } if (request.From.HasValue) { whereClauses.Add("timestamp >= $from"); parameters.Add(new SqliteParameter("$from", request.From.Value.ToString("o"))); } if (request.To.HasValue) { whereClauses.Add("timestamp <= $to"); parameters.Add(new SqliteParameter("$to", request.To.Value.ToString("o"))); } if (!string.IsNullOrWhiteSpace(request.EventType)) { whereClauses.Add("event_type = $eventType"); parameters.Add(new SqliteParameter("$eventType", request.EventType)); } if (!string.IsNullOrWhiteSpace(request.Severity)) { whereClauses.Add("severity = $severity"); parameters.Add(new SqliteParameter("$severity", request.Severity)); } if (!string.IsNullOrWhiteSpace(request.InstanceId)) { whereClauses.Add("instance_id = $instanceId"); parameters.Add(new SqliteParameter("$instanceId", request.InstanceId)); } if (!string.IsNullOrWhiteSpace(request.KeywordFilter)) { // Keyword search is a literal substring match. The LIKE // metacharacters % and _ (and the escape char itself) must be // escaped so identifiers such as "store_and_forward" or a literal // "%" are not misinterpreted as wildcards (SiteEventLogging-013). var escaped = EscapeLikePattern(request.KeywordFilter); whereClauses.Add( "(message LIKE $keyword ESCAPE '\\' OR source LIKE $keyword ESCAPE '\\')"); parameters.Add(new SqliteParameter("$keyword", $"%{escaped}%")); } var whereClause = whereClauses.Count > 0 ? "WHERE " + string.Join(" AND ", whereClauses) : ""; // Run the read against the shared connection under the logger's write // lock — the connection is not thread-safe and is also used by the // recorder and the purge service on other threads. var entries = _eventLogger.WithConnection(connection => { using var cmd = connection.CreateCommand(); // Fetch pageSize + 1 to determine if there are more results cmd.CommandText = $""" SELECT id, timestamp, event_type, severity, instance_id, source, message, details FROM site_events {whereClause} ORDER BY id ASC LIMIT $limit """; cmd.Parameters.AddWithValue("$limit", pageSize + 1); foreach (var p in parameters) cmd.Parameters.Add(p); var rows = new List(); using var reader = cmd.ExecuteReader(); while (reader.Read()) { rows.Add(new EventLogEntry( Id: reader.GetInt64(0), Timestamp: DateTimeOffset.Parse(reader.GetString(1)), EventType: reader.GetString(2), Severity: reader.GetString(3), InstanceId: reader.IsDBNull(4) ? null : reader.GetString(4), Source: reader.GetString(5), Message: reader.GetString(6), Details: reader.IsDBNull(7) ? null : reader.GetString(7))); } return rows; }); var hasMore = entries.Count > pageSize; if (hasMore) { entries.RemoveAt(entries.Count - 1); } var continuationToken = entries.Count > 0 ? entries[^1].Id : (long?)null; return new EventLogQueryResponse( CorrelationId: request.CorrelationId, SiteId: request.SiteId, Entries: entries, ContinuationToken: continuationToken, HasMore: hasMore, Success: true, ErrorMessage: null, Timestamp: DateTimeOffset.UtcNow); } catch (Exception ex) { _logger.LogError(ex, "Failed to execute event log query: {CorrelationId}", request.CorrelationId); return new EventLogQueryResponse( CorrelationId: request.CorrelationId, SiteId: request.SiteId, Entries: [], ContinuationToken: null, HasMore: false, Success: false, ErrorMessage: ex.Message, Timestamp: DateTimeOffset.UtcNow); } } }