feat: normalize release-cardinal fields out of track into a Release entity (Phase 8 §8.0)
This commit is contained in:
@@ -11,18 +11,26 @@ namespace DeepDrftData.Repositories;
|
||||
|
||||
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
{
|
||||
// The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) but no
|
||||
// DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference
|
||||
// to the injected context rather than reaching for a service locator — it is the same scoped
|
||||
// instance the base holds, so reads/writes stay in one unit of work.
|
||||
private readonly DeepDrftContext _context;
|
||||
|
||||
public TrackRepository(
|
||||
DeepDrftContext context,
|
||||
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
|
||||
IDbExceptionClassifier? classifier = null)
|
||||
: base(context, logger, classifier: classifier)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
|
||||
// uses Query (soft-delete filtered) rather than the raw DbSet.
|
||||
// uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the
|
||||
// converter can project the release-cardinal fields.
|
||||
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
|
||||
=> await Query.FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
||||
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
||||
|
||||
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
|
||||
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
|
||||
@@ -37,6 +45,7 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
|
||||
var index = Random.Shared.Next(count);
|
||||
return await Query
|
||||
.Include(t => t.Release)
|
||||
.OrderBy(t => t.Id)
|
||||
.Skip(index)
|
||||
.Take(1)
|
||||
@@ -53,27 +62,29 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
TrackFilter? filter,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<TrackEntity> query = Query;
|
||||
// Include Release so both the filter predicates and the converter can read release-cardinal
|
||||
// fields through the navigation.
|
||||
IQueryable<TrackEntity> query = Query.Include(t => t.Release);
|
||||
|
||||
if (filter is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
||||
{
|
||||
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
||||
// EF-translatable where ToLower().Contains() is not. Album is nullable — ILike on a
|
||||
// null column yields false, which is the desired "no match" behaviour.
|
||||
// EF-translatable where ToLower().Contains() is not. Artist/Title live on the joined
|
||||
// Release, which is null for loose tracks — guard the navigation before ILike.
|
||||
var pattern = $"%{filter.SearchText}%";
|
||||
query = query.Where(t =>
|
||||
EF.Functions.ILike(t.TrackName, pattern)
|
||||
|| EF.Functions.ILike(t.Artist, pattern)
|
||||
|| (t.Album != null && EF.Functions.ILike(t.Album, pattern)));
|
||||
|| (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern))
|
||||
|| (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Album))
|
||||
query = query.Where(t => t.Album == filter.Album);
|
||||
query = query.Where(t => t.Release != null && t.Release.Title == filter.Album);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
||||
query = query.Where(t => t.Genre == filter.Genre);
|
||||
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
@@ -99,30 +110,21 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
};
|
||||
}
|
||||
|
||||
// Distinct albums (non-null) with track counts and a representative cover key. The cover is the
|
||||
// first non-null ImagePath in the group; GroupBy + projection keeps it a single round-trip.
|
||||
public async Task<List<AlbumSummaryDto>> GetDistinctAlbumsAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.Album != null)
|
||||
.GroupBy(t => t.Album!)
|
||||
.Select(g => new AlbumSummaryDto
|
||||
{
|
||||
Album = g.Key,
|
||||
TrackCount = g.Count(),
|
||||
CoverImageKey = g
|
||||
.Where(t => t.ImagePath != null)
|
||||
.OrderBy(t => t.Id)
|
||||
.Select(t => t.ImagePath)
|
||||
.FirstOrDefault(),
|
||||
})
|
||||
.OrderBy(a => a.Album)
|
||||
// All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks.
|
||||
// The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto.
|
||||
public async Task<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.Where(r => !r.IsDeleted)
|
||||
.OrderBy(r => r.Title)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Distinct genres (non-null) with track counts.
|
||||
// Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks
|
||||
// (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks
|
||||
// (no release) carry no genre and are excluded.
|
||||
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.Genre != null)
|
||||
.GroupBy(t => t.Genre!)
|
||||
.Where(t => t.Release != null && t.Release.Genre != null)
|
||||
.GroupBy(t => t.Release!.Genre!)
|
||||
.Select(g => new GenreSummaryDto
|
||||
{
|
||||
Genre = g.Key,
|
||||
@@ -131,16 +133,52 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
.OrderBy(g => g.Genre)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against
|
||||
// GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out.
|
||||
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.ReleaseId != null)
|
||||
.GroupBy(t => t.ReleaseId!.Value)
|
||||
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
||||
|
||||
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
|
||||
// signalling the manager to create one. Soft-deleted releases never match.
|
||||
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
|
||||
string title, string artist, CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct);
|
||||
|
||||
// Persist a new release row and return it with its assigned Id. Lives here (not the manager)
|
||||
// because the repository owns the DbContext — the manager stays free of direct context access.
|
||||
public async Task<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||
{
|
||||
_context.Set<ReleaseEntity>().Add(release);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return release;
|
||||
}
|
||||
|
||||
// Load a tracked release by id so the manager can edit its fields in place and save. Returns
|
||||
// null when the id does not resolve (or the release is soft-deleted).
|
||||
public async Task<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct);
|
||||
|
||||
// Persist edits to a release. Update marks the whole entity modified, so it works whether the
|
||||
// instance is the change-tracked one from GetReleaseByIdAsync or a detached graph.
|
||||
public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||
{
|
||||
_context.Set<ReleaseEntity>().Update(release);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
|
||||
{
|
||||
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
|
||||
target.EntryKey = source.EntryKey;
|
||||
target.TrackName = source.TrackName;
|
||||
target.Artist = source.Artist;
|
||||
target.Album = source.Album;
|
||||
target.Genre = source.Genre;
|
||||
target.ReleaseDate = source.ReleaseDate;
|
||||
target.ImagePath = source.ImagePath;
|
||||
target.CreatedByUserId = source.CreatedByUserId;
|
||||
target.TrackNumber = source.TrackNumber;
|
||||
target.OriginalFileName = source.OriginalFileName;
|
||||
target.ReleaseId = source.ReleaseId;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user