Première relation entre livres du projet. Une série contient des séries (un cycle EST une série de séries) et des PLACES dans l'ordre de lecture — pas des livres : une place sans livre est le trou qu'on vient voir. L'ordre est stocké, jamais déduit d'une année : une préquelle se lit avant le livre paru dix ans plus tôt. Les tomes non possédés se saisissent à la main, aucune source ne donnant l'ordre de lecture d'une saga. Le titre de la place survit à la suppression du livre (SetNull, pas de cascade), sans quoi perdre un exemplaire effacerait le tome 3 de la série. Les séries sont communes au foyer. Seule exception : la mise en envies d'un tome manquant, qui écrit dans une liste personnelle — c'est le seul point où les deux portées se rencontrent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
9.1 KiB
C#
203 lines
9.1 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<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>
|
|
/// 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>();
|
|
|
|
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.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<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<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<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");
|
|
});
|
|
}
|
|
}
|