Files
mabibli/MaBibli.Api/Data/MaBibliDbContext.cs
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

338 lines
15 KiB
C#

using MaBibli.Shared.Entites;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Data;
public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbContext(options)
{
public DbSet<Livre> Livres => Set<Livre>();
public DbSet<Auteur> Auteurs => Set<Auteur>();
public DbSet<LivreAuteur> LivreAuteurs => Set<LivreAuteur>();
public DbSet<Theme> Themes => Set<Theme>();
public DbSet<LivreTheme> LivreThemes => Set<LivreTheme>();
public DbSet<StatutLecture> StatutsLecture => Set<StatutLecture>();
public DbSet<RapprochementRefuse> RapprochementsRefuses => Set<RapprochementRefuse>();
public DbSet<Pret> Prets => Set<Pret>();
/// <summary>
/// Liste d'envies, <b>personnelle</b>. Table séparée des <see cref="Livres"/> : un livre
/// souhaité n'est pas un livre possédé, et ne doit apparaître ni au catalogue, ni dans les
/// compteurs, ni dans les prêts.
/// </summary>
public DbSet<LivreSouhaite> LivresSouhaites => Set<LivreSouhaite>();
/// <summary>
/// Envies de revues et de numéros, <b>personnelles</b>. Table sœur de
/// <see cref="LivresSouhaites"/> : un numéro n'a pas d'auteur, et se distingue par son
/// numéro — deux choses que la clé d'unicité des envies de livres ne sait pas dire.
/// </summary>
public DbSet<RevueSouhaitee> RevuesSouhaitees => Set<RevueSouhaitee>();
public DbSet<BibliographieMasquee> BibliographiesMasquees => Set<BibliographieMasquee>();
/// <summary>
/// Sagas, cycles et séries. <b>Communs au foyer</b>, comme le catalogue : l'ordre de lecture
/// est une propriété de l'œuvre, pas du lecteur.
/// </summary>
public DbSet<Serie> Series => Set<Serie>();
public DbSet<ElementSerie> ElementsSerie => Set<ElementSerie>();
/// <summary>
/// Revues et magazines. <b>Table séparée des <see cref="Livres"/></b> : une revue n'est pas
/// un livre, et n'a rien à faire dans le catalogue, ses compteurs ou ses doublons.
/// </summary>
public DbSet<Revue> Revues => Set<Revue>();
public DbSet<NumeroRevue> NumerosRevue => Set<NumeroRevue>();
/// <summary>
/// Articles à la une d'un numéro. Table à part de <see cref="Themes"/> : un titre d'article
/// est unique à sa parution et ne se réutilise jamais, là où un thème est un vocabulaire.
/// </summary>
public DbSet<ArticleUne> ArticlesUne => Set<ArticleUne>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Livre>(livre =>
{
livre.Property(l => l.Titre).IsRequired();
livre.Property(l => l.TitreNormalise).IsRequired();
livre.Property(l => l.UrlNotice);
livre.HasIndex(l => l.Isbn);
// Le tri et la recherche passent par là : sans index, chaque frappe balaie la table.
livre.HasIndex(l => l.TitreNormalise);
livre.HasMany(l => l.Prets)
.WithOne(p => p.Livre)
.HasForeignKey(p => p.LivreId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Auteur>(auteur =>
{
auteur.Property(a => a.Nom).IsRequired();
auteur.Property(a => a.NomNormalise).IsRequired();
auteur.Property(a => a.CleRegroupement).IsRequired();
// Unicité de la clé de regroupement : « Zola, Émile » et « Émile Zola » produisent
// la même clé et ne peuvent donc pas coexister. C'est la garantie structurelle du
// regroupement — le code applicatif peut avoir un trou, la base non.
auteur.HasIndex(a => a.CleRegroupement).IsUnique();
auteur.HasIndex(a => a.NomNormalise);
});
modelBuilder.Entity<LivreAuteur>(lien =>
{
lien.HasKey(la => new { la.LivreId, la.AuteurId });
lien.HasOne(la => la.Livre)
.WithMany(l => l.Auteurs)
.HasForeignKey(la => la.LivreId)
.OnDelete(DeleteBehavior.Cascade);
// Restrict : supprimer un auteur encore rattaché à des livres les priverait de leur
// auteur en silence. On passe par une fusion, ou par la suppression des livres.
lien.HasOne(la => la.Auteur)
.WithMany(a => a.Livres)
.HasForeignKey(la => la.AuteurId)
.OnDelete(DeleteBehavior.Restrict);
lien.HasIndex(la => la.AuteurId);
});
modelBuilder.Entity<Theme>(theme =>
{
theme.Property(t => t.Nom).IsRequired();
theme.Property(t => t.NomNormalise).IsRequired();
theme.HasIndex(t => t.NomNormalise).IsUnique();
});
modelBuilder.Entity<LivreTheme>(lien =>
{
lien.HasKey(lt => new { lt.LivreId, lt.ThemeId });
lien.HasOne(lt => lt.Livre)
.WithMany(l => l.Themes)
.HasForeignKey(lt => lt.LivreId)
.OnDelete(DeleteBehavior.Cascade);
lien.HasOne(lt => lt.Theme)
.WithMany(t => t.Livres)
.HasForeignKey(lt => lt.ThemeId)
.OnDelete(DeleteBehavior.Restrict);
lien.HasIndex(lt => lt.ThemeId);
});
modelBuilder.Entity<StatutLecture>(statut =>
{
statut.Property(s => s.Utilisateur).IsRequired();
statut.HasOne(s => s.Livre)
.WithMany(l => l.Statuts)
.HasForeignKey(s => s.LivreId)
.OnDelete(DeleteBehavior.Cascade);
// Une personne, un statut par livre. L'unicité est ce qui rend l'écriture idempotente :
// on lit la ligne, on la met à jour, et il ne peut jamais y en avoir deux à départager.
statut.HasIndex(s => new { s.LivreId, s.Utilisateur }).IsUnique();
statut.HasIndex(s => s.Utilisateur);
});
modelBuilder.Entity<RapprochementRefuse>(refus =>
{
// Le couple est rangé (petit identifiant d'abord) à l'écriture : l'unicité vaut donc
// dans les deux sens, et un refus ne peut pas être enregistré deux fois.
refus.HasIndex(r => new { r.AuteurAId, r.AuteurBId }).IsUnique();
});
modelBuilder.Entity<LivreSouhaite>(souhait =>
{
souhait.Property(s => s.Utilisateur).IsRequired();
souhait.Property(s => s.Titre).IsRequired();
souhait.Property(s => s.TitreNormalise).IsRequired();
// Jamais nullable : SQLite tient deux NULL pour distincts, et l'unicité ci-dessous
// laisserait alors passer autant de doublons qu'on veut sur les envies sans auteur.
souhait.Property(s => s.AuteurNormalise).IsRequired().HasDefaultValue(string.Empty);
// Toute lecture part de là : la liste est personnelle, on la parcourt par personne.
souhait.HasIndex(s => s.Utilisateur, "IX_LivresSouhaites_Utilisateur");
// Une même œuvre ne s'ajoute qu'une fois par personne — l'écran de bibliographie
// rend le double clic facile. L'auteur fait partie de la clé : deux auteurs peuvent
// avoir publié deux « Œuvres » différentes, et l'un ne doit pas bloquer l'autre.
// Deux personnes peuvent en revanche souhaiter le même livre, d'où l'utilisateur
// en tête de l'index.
souhait.HasIndex(
s => new { s.Utilisateur, s.TitreNormalise, s.AuteurNormalise },
"IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
});
modelBuilder.Entity<RevueSouhaitee>(envie =>
{
envie.Property(e => e.Utilisateur).IsRequired();
envie.Property(e => e.Titre).IsRequired();
envie.Property(e => e.TitreNormalise).IsRequired();
// Jamais nullable, exactement pour la raison qui l'impose à AuteurNormalise : SQLite
// tient deux NULL pour distincts, et « Médor, pas de numéro » s'ajouterait autant de
// fois qu'on cliquerait.
envie.Property(e => e.NumeroNormalise).IsRequired().HasDefaultValue(string.Empty);
envie.HasIndex(e => e.Utilisateur, "IX_RevuesSouhaitees_Utilisateur");
// Le numéro fait partie de la clé : souhaiter le 43 ET le 44 de la même revue est le
// cas normal, alors que noter deux fois le même numéro est une fausse manœuvre.
// L'utilisateur ouvre l'index : deux personnes peuvent vouloir la même parution.
envie.HasIndex(
e => new { e.Utilisateur, e.TitreNormalise, e.NumeroNormalise },
"IX_RevuesSouhaitees_Utilisateur_Revue")
.IsUnique();
});
modelBuilder.Entity<BibliographieMasquee>(masquee =>
{
masquee.Property(m => m.Utilisateur).IsRequired();
masquee.Property(m => m.TitreNormalise).IsRequired();
masquee.HasIndex(
m => new { m.AuteurId, m.Utilisateur, m.TitreNormalise },
"IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
masquee.HasIndex(m => new { m.Utilisateur, m.AuteurId });
});
modelBuilder.Entity<Serie>(serie =>
{
serie.Property(s => s.Titre).IsRequired();
serie.Property(s => s.TitreNormalise).IsRequired();
// Une série, une fiche. Voir l'entité : l'unicité est GLOBALE et non par parent,
// le parent étant nullable et deux NULL étant distincts pour SQLite.
serie.HasIndex(s => s.TitreNormalise).IsUnique();
// SetNull et non Cascade : supprimer un cycle ne doit pas emporter les trilogies
// qu'il contient — ce sont de vraies séries de vrais livres, qui se tiennent seules.
serie.HasOne(s => s.SerieParente)
.WithMany(s => s.SousSeries)
.HasForeignKey(s => s.SerieParenteId)
.OnDelete(DeleteBehavior.SetNull);
serie.HasIndex(s => s.SerieParenteId);
});
modelBuilder.Entity<ElementSerie>(element =>
{
element.Property(e => e.Titre).IsRequired();
element.HasOne(e => e.Serie)
.WithMany(s => s.Elements)
.HasForeignKey(e => e.SerieId)
.OnDelete(DeleteBehavior.Cascade);
// ⚠️ SetNull, surtout pas Cascade : supprimer un livre du catalogue doit laisser sa
// place dans la saga, devenue un trou nommé. Cascade effacerait le tome 3 de la
// série parce qu'on a prêté puis perdu son exemplaire.
element.HasOne(e => e.Livre)
.WithMany()
.HasForeignKey(e => e.LivreId)
.OnDelete(DeleteBehavior.SetNull);
element.HasIndex(e => e.SerieId, "IX_ElementsSerie_SerieId");
// Un même livre ne tient qu'une place dans une série donnée. Index PARTIEL, comme
// pour les prêts : les positions sans livre (les trous) doivent pouvoir se répéter
// autant que nécessaire, et deux NULL passeraient de toute façon.
// Un livre peut en revanche appartenir à PLUSIEURS séries — une intégrale, une
// série, un cycle — d'où l'absence d'unicité sur LivreId seul.
element.HasIndex(e => new { e.SerieId, e.LivreId }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
});
modelBuilder.Entity<Revue>(revue =>
{
revue.Property(r => r.Titre).IsRequired();
revue.Property(r => r.TitreNormalise).IsRequired();
revue.HasIndex(r => r.TitreNormalise).IsUnique();
// Index PARTIEL : une revue peut n'avoir aucun ISSN (saisie à la main), et deux
// NULL étant distincts pour SQLite, une unicité simple ne dirait rien. Filtré, il
// garantit qu'un ISSN connu ne désigne qu'une fiche — ce qui permet au flux « 977 »
// de retrouver la revue déjà créée au lieu d'en faire une seconde.
revue.HasIndex(r => r.Issn, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
});
modelBuilder.Entity<NumeroRevue>(numero =>
{
numero.Property(n => n.Numero).IsRequired();
numero.Property(n => n.NumeroNormalise).IsRequired();
numero.HasOne(n => n.Revue)
.WithMany(r => r.Numeros)
.HasForeignKey(n => n.RevueId)
.OnDelete(DeleteBehavior.Cascade);
// Un numéro ne se possède qu'une fois par revue : rescanner le même exemplaire ne
// doit pas allonger la liste. Contrairement aux livres, un second exemplaire d'un
// numéro de magazine n'a pas d'usage identifié — et l'index se retire si un jour si.
numero.HasIndex(n => new { n.RevueId, n.NumeroNormalise }).IsUnique();
});
modelBuilder.Entity<ArticleUne>(article =>
{
article.Property(a => a.Titre).IsRequired();
article.Property(a => a.TitreNormalise).IsRequired();
// Cascade, comme les numéros sous leur revue : un article à la une n'existe que par
// la parution qui le porte. Rien de comparable à ElementSerie, dont la place doit
// survivre au livre.
article.HasOne(a => a.NumeroRevue)
.WithMany(n => n.Articles)
.HasForeignKey(a => a.NumeroRevueId)
.OnDelete(DeleteBehavior.Cascade);
// Le même article ne s'annonce pas deux fois à la une du même numéro. L'unicité est
// bornée au numéro : deux parutions peuvent parfaitement titrer pareil.
article.HasIndex(a => new { a.NumeroRevueId, a.TitreNormalise }).IsUnique();
});
modelBuilder.Entity<Pret>(pret =>
{
pret.Property(p => p.Emprunteur).IsRequired();
// Deux index sur la même colonne, et ce n'est pas une redondance. Ils doivent porter
// un nom explicite : EF identifie un index par ses colonnes, et sans nom distinct le
// second remplacerait purement et simplement le premier.
// Celui-ci sert l'historique d'un livre — toutes ses lignes, closes comprises.
pret.HasIndex(p => p.LivreId, "IX_Prets_LivreId");
// Celui-là est une contrainte : il ne peut y avoir qu'UN prêt ouvert par livre, on
// ne prête pas deux fois un exemplaire qui n'est pas revenu. Le service le vérifie,
// mais entre sa vérification et son insertion il y a une fenêtre ; l'index la ferme.
// Le filtre est ce qui rend la chose possible : seules les lignes ouvertes sont
// indexées, les prêts clos restent libres de se répéter autant que nécessaire.
// Il sert accessoirement la vue « prêts en cours », dont c'est exactement le critère.
pret.HasIndex(p => p.LivreId, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
});
}
}