f294255f10
- 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.
153 lines
5.4 KiB
C#
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);
|
|
}
|