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>
338 lines
15 KiB
C#
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");
|
|
});
|
|
}
|
|
}
|