mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
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.
323 lines
14 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|