Files
maraphon-app/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs
T
alexei.dolgolyov f294255f10 perf: batch repository reads, index snapshots, centralize date encoding
- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at
  6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing,
  results selection); guarded by a Received(1).GetManyAsync test.
- Add EventRepository.QueryAsync to push date+sport filtering to SQL (was
  load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order.
- Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync
  (feed date filter); add Event/Snapshot count methods for the dashboard.
- Add composite indexes IX_Snapshots_EventCode_CapturedAt and
  _EventCode_Source_CapturedAt via a new migration + model snapshot.
- Introduce SqliteDateText as the single source of the O-format date encoding
  shared by Mapping (read/write) and the repositories' range predicates.
- Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make
  DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join.

Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
2026-05-28 22:34:08 +03:00

153 lines
5.4 KiB
C#

using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class EventRepository : IEventRepository
{
private readonly MarathonDbContext _db;
public EventRepository(MarathonDbContext db) => _db = db;
public async Task<Event?> GetAsync(EventId key, CancellationToken ct = default)
{
var entity = await _db.Events.FindAsync([key.Value], ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<Event>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.Events.AsNoTracking().ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
{
// ScheduledAt is stored as ISO 8601 TEXT (see SqliteDateText); SQLite TEXT
// comparison sorts chronologically for the fixed-offset O format.
var fromStr = SqliteDateText.Key(range.From);
var toStr = SqliteDateText.Key(range.To);
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
// translate the relational operators on string columns (which use BINARY/ordinal
// collation by default in SQLite — correct for ISO 8601).
var entities = await _db.Events.AsNoTracking()
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
&& e.ScheduledAt.CompareTo(toStr) <= 0)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(query);
var fromStr = SqliteDateText.Key(query.Dates.From);
var toStr = SqliteDateText.Key(query.Dates.To);
// Date range + sport filter pushed to SQL so a multi-sport page no longer
// materialises every event in the window. The composite
// IX_Events_SportCode_ScheduledAt index covers this predicate. Case-sensitive
// search / country filtering and locale-aware sorting stay in the service
// layer where Cyrillic ordinal semantics are preserved.
var q = _db.Events.AsNoTracking()
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
&& e.ScheduledAt.CompareTo(toStr) <= 0);
if (query.SportCodes is { Count: > 0 } sports)
{
var sportArray = sports.Distinct().ToArray();
q = q.Where(e => sportArray.Contains(e.SportCode));
}
var entities = await q.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(ids);
var result = new Dictionary<EventId, Event>(ids.Count);
if (ids.Count == 0)
return result;
var codes = ids.Select(e => e.Value).Distinct().ToArray();
var entities = await _db.Events.AsNoTracking()
.Where(e => codes.Contains(e.EventCode))
.ToListAsync(ct);
foreach (var entity in entities)
{
var domain = Mapping.ToDomain(entity);
result[domain.Id] = domain;
}
return result;
}
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
{
var entities = await _db.Events.AsNoTracking()
.Where(e => e.SportCode == sport.Value)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public Task<int> CountAsync(CancellationToken ct = default) =>
_db.Events.AsNoTracking().CountAsync(ct);
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
{
var codes = await _db.Events.AsNoTracking()
.Select(e => e.SportCode)
.Distinct()
.ToListAsync(ct);
codes.Sort();
return codes;
}
public async Task<IReadOnlyList<string>> ListDistinctCountryCodesAsync(CancellationToken ct = default)
{
var codes = await _db.Events.AsNoTracking()
.Select(e => e.CountryCode)
.Distinct()
.ToListAsync(ct);
codes.Sort(StringComparer.OrdinalIgnoreCase);
return codes;
}
public async Task AddAsync(Event entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.Events.AddAsync(efEntity, ct);
}
public Task UpdateAsync(Event entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.Events.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(EventId key, CancellationToken ct = default)
{
var entity = await _db.Events.FindAsync([key.Value], ct);
if (entity is not null)
_db.Events.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}