using MaBibli.Shared.Entites; using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Data; public class MaBibliDbContext(DbContextOptions options) : DbContext(options) { public DbSet Livres => Set(); public DbSet Auteurs => Set(); public DbSet LivreAuteurs => Set(); public DbSet Themes => Set(); public DbSet LivreThemes => Set(); public DbSet StatutsLecture => Set(); public DbSet RapprochementsRefuses => Set(); public DbSet Prets => Set(); /// /// Liste d'envies, personnelle. Table séparée des : 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. /// public DbSet LivresSouhaites => Set(); /// /// Envies de revues et de numéros, personnelles. Table sœur de /// : 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. /// public DbSet RevuesSouhaitees => Set(); public DbSet BibliographiesMasquees => Set(); /// /// Sagas, cycles et séries. Communs au foyer, comme le catalogue : l'ordre de lecture /// est une propriété de l'œuvre, pas du lecteur. /// public DbSet Series => Set(); public DbSet ElementsSerie => Set(); /// /// Revues et magazines. Table séparée des : une revue n'est pas /// un livre, et n'a rien à faire dans le catalogue, ses compteurs ou ses doublons. /// public DbSet Revues => Set(); public DbSet NumerosRevue => Set(); /// /// Articles à la une d'un numéro. Table à part de : un titre d'article /// est unique à sa parution et ne se réutilise jamais, là où un thème est un vocabulaire. /// public DbSet ArticlesUne => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity(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.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(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.Property(t => t.Nom).IsRequired(); theme.Property(t => t.NomNormalise).IsRequired(); theme.HasIndex(t => t.NomNormalise).IsUnique(); }); modelBuilder.Entity(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(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(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(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(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(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.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(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.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(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(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.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"); }); } }