Files
marechai/Marechai.Database/Interceptors/SearchIndexInterceptor.cs
Natalia Portillo e399e14d38 feat: Make software compilations an independent entity
Compilations were previously modeled as a SoftwareRelease row with
IsCompilation=true, with bundled contents tracked via release-keyed
junction tables. This conflated "release" with "the thing being
released" and blocked compilations from having their own identity,
predecessor chain, or covers independent of any one release.

Introduce SoftwareCompilation as a sibling entity to Software:
- Has its own Name, optional bundling link to a Software or Machine
  (e.g. games bundled with an OS, or a dedicated handheld), and a
  predecessor/successor self-reference reusing SoftwareRelationshipType.
- Owns its releases (SoftwareRelease.SoftwareCompilationId) and covers
  (SoftwareCover.SoftwareCompilationId), mirroring Software's existing
  patterns.
- Owns its contained software/versions via new SoftwareBySoftwareCompilation
  / SoftwareVersionBySoftwareCompilation junctions (moved off
  SoftwareRelease, since different releases of the same compilation
  contain the same games).
- Can itself be nested inside other compilations via
  SoftwareCompilationBySoftwareCompilation, a many-to-many junction
  (a sub-compilation can ship inside several different bundles). This
  also fixes a MobyGames importer bug where a compilation containing
  another compilation as one of its titles couldn't be resolved, because
  the original Software row backing the inner compilation was deleted
  with no replacement pointer recorded; MobyGamesImportState now gets a
  SoftwareCompilationId for exactly this case.

Three migrations carry out the conversion: schema addition, a data
backfill that converts existing IsCompilation=true releases into real
SoftwareCompilation rows (re-pointing their releases, covers, and
junction rows), and a cleanup migration dropping the old column/tables.
Applied and verified against the dev database.

Add SoftwareCompilationsController with CRUD, releases, covers,
included-software/version junctions, and nested-compilation endpoints
(with a cycle guard). Update SoftwareReleasesController,
SoftwareController's catalog UNION queries, SearchController, and
SoftwareReleaseSuggestionApplier to use SoftwareCompilationId instead
of the removed IsCompilation flag.

Remaining work (Marechai.ApiClient DTO mirrors, Blazor UI, Marechai.App
compile fixes, MobyGames CompilationRelationService rewrite) is tracked
separately and not yet included in this commit.
2026-06-26 03:51:51 +01:00

323 lines
14 KiB
C#

/******************************************************************************
// MARECHAI: Master repository of computing history artifacts information
// ----------------------------------------------------------------------------
//
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// --[ License ] --------------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ----------------------------------------------------------------------------
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Marechai.Data;
using Marechai.Database.Helpers;
using Marechai.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Marechai.Database.Interceptors;
/// <summary>
/// Listens for SaveChanges and synchronizes the SearchEntries denormalized index for the
/// 13 searchable entity types whenever any of them are added / modified / deleted.
///
/// Two-phase strategy:
/// <list type="number">
/// <item><description>
/// <see cref="SavingChanges"/> snapshots which tracked entities will need a sync.
/// For deletes we MUST capture type + id now (the entity is detached after SaveChanges).
/// For inserts/updates we hold the entity reference; the autogenerated PK lands on
/// the same instance after the primary write.
/// </description></item>
/// <item><description>
/// <see cref="SavedChanges"/> applies upserts/deletes with the now-populated PKs and
/// a second SaveChanges call. Index updates therefore commit in a separate transaction
/// from the primary write — acceptable for a search index (eventual consistency on failure).
/// </description></item>
/// </list>
///
/// <see cref="AsyncLocal{T}"/> is used to carry the snapshot across the SaveChanges boundary
/// because the EF interceptor instance is shared across all DbContext instances configured
/// with the same options (and is therefore stateless on its own).
/// </summary>
public sealed class SearchIndexInterceptor : SaveChangesInterceptor
{
static readonly AsyncLocal<List<PendingSync>> _pending = new();
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData,
InterceptionResult<int> result)
{
if(eventData.Context is MarechaiContext ctx) Snapshot(ctx);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if(eventData.Context is MarechaiContext ctx) Snapshot(ctx);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
if(eventData.Context is MarechaiContext ctx) Apply(ctx);
return base.SavedChanges(eventData, result);
}
public override async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData, int result,
CancellationToken cancellationToken = default)
{
if(eventData.Context is MarechaiContext ctx) await ApplyAsync(ctx, cancellationToken);
return await base.SavedChangesAsync(eventData, result, cancellationToken);
}
static void Snapshot(MarechaiContext ctx)
{
var pending = new List<PendingSync>();
foreach(EntityEntry entry in ctx.ChangeTracker.Entries().ToList())
{
if(entry.State == EntityState.Detached || entry.State == EntityState.Unchanged) continue;
// Skip our own SearchEntry writes from a previous Apply that reused the same context.
if(entry.Entity is SearchEntry) continue;
PendingSync? p = TryBuildPending(entry);
if(p.HasValue) pending.Add(p.Value);
}
_pending.Value = pending.Count > 0 ? pending : null;
}
static void Apply(MarechaiContext ctx)
{
List<PendingSync> pending = _pending.Value;
if(pending == null || pending.Count == 0) return;
_pending.Value = null;
bool dirty = false;
foreach(PendingSync p in pending) dirty |= ApplyOne(ctx, p);
if(dirty) ctx.SaveChanges();
}
static async Task ApplyAsync(MarechaiContext ctx, CancellationToken ct)
{
List<PendingSync> pending = _pending.Value;
if(pending == null || pending.Count == 0) return;
_pending.Value = null;
bool dirty = false;
foreach(PendingSync p in pending) dirty |= ApplyOne(ctx, p);
if(dirty) await ctx.SaveChangesAsync(ct);
}
static bool ApplyOne(MarechaiContext ctx, PendingSync p)
{
if(p.IsDelete)
{
SearchEntityType pType = p.Type;
long pId = p.EntityId;
SearchEntry existing = ctx.SearchEntries.FirstOrDefault(e => e.EntityType == pType && e.EntityId == pId);
if(existing != null) { ctx.SearchEntries.Remove(existing); return true; }
return false;
}
// Re-extract field values from the entity reference; PKs are now populated for inserts.
return p.Entity switch
{
Company c => UpsertCompany(ctx, c),
Machine m => UpsertMachine(ctx, m),
Book b => UpsertBook(ctx, b),
Document d => UpsertDocument(ctx, d),
Magazine mg => UpsertMagazine(ctx, mg),
Gpu g => UpsertGpu(ctx, g),
Processor pr => UpsertProcessor(ctx, pr),
SoundSynth s => UpsertSoundSynth(ctx, s),
Person pe => UpsertPerson(ctx, pe),
Software sw => UpsertSoftware(ctx, sw),
SoftwareCompilation sc => UpsertSoftwareCompilation(ctx, sc),
_ => false
};
}
static PendingSync? TryBuildPending(EntityEntry entry)
{
bool isDelete = entry.State == EntityState.Deleted;
switch(entry.Entity)
{
case Company c:
return isDelete ? new PendingSync(SearchEntityType.Company, c.Id, true) : new PendingSync(c);
case Machine m:
return isDelete ? new PendingSync(MachineSearchType(m.Type), m.Id, true) : new PendingSync(m);
case Book b:
return isDelete ? new PendingSync(SearchEntityType.Book, b.Id, true) : new PendingSync(b);
case Document d:
return isDelete ? new PendingSync(SearchEntityType.Document, d.Id, true) : new PendingSync(d);
case Magazine mg:
return isDelete ? new PendingSync(SearchEntityType.Magazine, mg.Id, true) : new PendingSync(mg);
case Gpu g:
return isDelete ? new PendingSync(SearchEntityType.Gpu, g.Id, true) : new PendingSync(g);
case Processor p:
return isDelete ? new PendingSync(SearchEntityType.Processor, p.Id, true) : new PendingSync(p);
case SoundSynth s:
return isDelete ? new PendingSync(SearchEntityType.SoundSynth, s.Id, true) : new PendingSync(s);
case Person pe:
return isDelete ? new PendingSync(SearchEntityType.Person, pe.Id, true) : new PendingSync(pe);
case Software sw:
return isDelete ? new PendingSync(SearchEntityType.Software, (long)sw.Id, true) : new PendingSync(sw);
case SoftwareCompilation sc:
return isDelete
? new PendingSync(SearchEntityType.SoftwareCompilation, (long)sc.Id, true)
: new PendingSync(sc);
}
return null;
}
// ── per-entity upsert delegates ──
static bool UpsertCompany(MarechaiContext ctx, Company c)
{
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Company, c.Id, c.Name, c.LegalName,
SearchIndexUpdater.YearOf(c.Founded), c.CountryId, null, false);
return true;
}
static bool UpsertMachine(MarechaiContext ctx, Machine m)
{
SearchEntityType type = MachineSearchType(m.Type);
bool hasImg = ctx.MachinePhotos.Any(p => p.MachineId == m.Id);
SearchIndexUpdater.Upsert(ctx, type, m.Id, m.Name, m.Model,
SearchIndexUpdater.YearOf(m.Introduced), null, m.CompanyId, hasImg);
return true;
}
static bool UpsertBook(MarechaiContext ctx, Book b)
{
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Book, b.Id, b.Title, b.NativeTitle,
SearchIndexUpdater.YearOf(b.Published), b.CountryId, null, b.CoverGuid.HasValue);
return true;
}
static bool UpsertDocument(MarechaiContext ctx, Document d)
{
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Document, d.Id, d.Title, d.NativeTitle,
SearchIndexUpdater.YearOf(d.Published), d.CountryId, null, false);
return true;
}
static bool UpsertMagazine(MarechaiContext ctx, Magazine mg)
{
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Magazine, mg.Id, mg.Title, mg.NativeTitle,
SearchIndexUpdater.YearOf(mg.FirstPublication ?? mg.Published), mg.CountryId, null,
false);
return true;
}
static bool UpsertGpu(MarechaiContext ctx, Gpu g)
{
bool hasImg = ctx.GpuPhotos.Any(p => p.GpuId == g.Id);
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Gpu, g.Id, g.Name, g.ModelCode,
SearchIndexUpdater.YearOf(g.Introduced), null, g.CompanyId, hasImg);
return true;
}
static bool UpsertProcessor(MarechaiContext ctx, Processor p)
{
bool hasImg = ctx.ProcessorPhotos.Any(ph => ph.ProcessorId == p.Id);
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Processor, p.Id, p.Name, p.ModelCode,
SearchIndexUpdater.YearOf(p.Introduced), null, p.CompanyId, hasImg);
return true;
}
static bool UpsertSoundSynth(MarechaiContext ctx, SoundSynth s)
{
bool hasImg = ctx.SoundSynthPhotos.Any(p => p.SoundSynthId == s.Id);
SearchIndexUpdater.Upsert(ctx, SearchEntityType.SoundSynth, s.Id, s.Name, s.ModelCode,
SearchIndexUpdater.YearOf(s.Introduced), null, s.CompanyId, hasImg);
return true;
}
static bool UpsertPerson(MarechaiContext ctx, Person pe)
{
string display = $"{pe.Name} {pe.Surname}".Trim();
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Person, pe.Id, display, pe.Alias,
pe.BirthDate.Year, null, null, pe.Photo != System.Guid.Empty);
return true;
}
static bool UpsertSoftware(MarechaiContext ctx, Software sw)
{
// HasImage: any screenshot, promo art, OR a cover on any of this software's releases.
ulong swId = sw.Id;
bool hasImg = ctx.SoftwareScreenshots.Any(s => s.SoftwareId == swId) ||
ctx.SoftwarePromoArt.Any(p => p.SoftwareId == swId) ||
ctx.SoftwareCovers.Any(c => c.SoftwareId == swId);
SearchIndexUpdater.Upsert(ctx, SearchEntityType.Software, (long)sw.Id, sw.Name, null, null, null, null, hasImg,
(byte)sw.Kind);
return true;
}
static bool UpsertSoftwareCompilation(MarechaiContext ctx, SoftwareCompilation sc)
{
bool hasImg = ctx.SoftwareCovers.Any(c => c.SoftwareCompilationId == sc.Id);
// Compilations are always games per project policy.
SearchIndexUpdater.Upsert(ctx, SearchEntityType.SoftwareCompilation, (long)sc.Id, sc.Name, null,
null, null, null, hasImg,
(byte)Marechai.Data.SoftwareKind.Game);
return true;
}
static SearchEntityType MachineSearchType(MachineType t) => t switch
{
MachineType.Console => SearchEntityType.Console,
MachineType.Smartphone => SearchEntityType.Smartphone,
MachineType.Pda => SearchEntityType.Pda,
MachineType.Tablet => SearchEntityType.Tablet,
_ => SearchEntityType.Computer
};
/// <summary>Captured intent for one searchable-entity change.</summary>
readonly struct PendingSync
{
public readonly SearchEntityType Type;
public readonly long EntityId;
public readonly bool IsDelete;
public readonly object Entity;
public PendingSync(SearchEntityType type, long entityId, bool isDelete)
{
Type = type;
EntityId = entityId;
IsDelete = isDelete;
Entity = null;
}
public PendingSync(object entity)
{
Type = default;
EntityId = 0;
IsDelete = false;
Entity = entity;
}
}
}