Aller au contenu

2. Les données

L’œuvre

models/title.py :

"""``library_title`` -- a work the library holds, in one or more copies."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kewos.orm.fields import CharField, FileField, IntegerField, SelectionField
from kewos.search import SearchableDescriptor, is_searchable, register_searchable
if TYPE_CHECKING:
from kewos.orm.registry import Registry
GENRES = [
("fiction", "Fiction"),
("science", "Science"),
("history", "History"),
("children", "Children"),
("reference", "Reference"),
]
def register_title(registry: Registry) -> None:
"""Declare ``library_title``, searchable, taggable, with a cover."""
registry.register_model(
"library_title",
module="library",
taggable=True,
fields=[
CharField(name="name", nullable=False, max_length=200),
CharField(name="author", nullable=False, max_length=120),
CharField(name="isbn", nullable=True, max_length=17, unique=True),
IntegerField(name="published_year", nullable=True),
SelectionField(name="genre", choices=GENRES, default="fiction"),
# A long text is a CharField with NO max_length.
CharField(name="summary", nullable=True),
FileField(name="cover", allowed_extensions=("png", "jpg", "webp", "svg")),
],
image_field="cover",
)
if not is_searchable("library_title"):
register_searchable(
SearchableDescriptor(model="library_title", title="name", subtitle="author")
)

Trois choses valent d’être remarquées.

unique=True sur l’ISBN pose une contrainte dans le schéma, pas une vérification dans un formulaire : deux processus qui insèrent le même ISBN en même temps ne peuvent pas passer tous les deux.

taggable=True branche le modèle sur le système d’étiquettes du noyau. Aucune table à créer, aucun champ à déclarer.

register_searchable le fait apparaître dans la recherche globale, avec un titre et un sous-titre. Une recherche métier ne s’écrit jamais à la main dans un module.

Les types de champ

TypePour
CharFieldUn texte. Avec max_length pour une ligne, sans pour un texte long.
IntegerField, BigIntegerFieldDes entiers.
DecimalFieldTout montant et toute quantité. precision et scale obligatoires.
BooleanFieldOui ou non.
DateField, DateTimeFieldUne date, un instant. Les instants sont toujours en UTC.
SelectionFieldUne liste fermée de valeurs.
JsonField, RichTextFieldUne structure, un contenu riche.
FileField, BinaryFieldUne pièce jointe, un contenu binaire.
Link, Backlink, LinkSetLes relations.
ComputedFieldUne valeur dérivée d’un calcul.
RelatedFieldUn raccourci vers un champ d’une relation.
AggregateFieldUne somme ou un compte sur les enfants d’une relation.

L’exemplaire, et le champ que tout le monde oublie

def _copy_name(instance: Any) -> str:
"""``<barcode> - <shelf>``, recomputed on every write."""
shelf = instance.shelf or "?"
return f"{instance.barcode} - {shelf}"
def register_copy(registry: Registry) -> None:
"""Declare ``library_copy``."""
registry.register_model(
"library_copy",
module="library",
fields=[
Link(name="title_id", target_model="library_title", nullable=False),
CharField(name="barcode", nullable=False, max_length=32, unique=True),
CharField(name="shelf", nullable=True, max_length=16),
SelectionField(name="condition", choices=CONDITIONS, default="good"),
DateField(name="acquired_on", nullable=True),
ComputedField(
name="name",
base_field=CharField(name="name", nullable=True, max_length=64),
compute=_copy_name,
depends=("barcode", "shelf"),
store=True,
),
],
)

Un ComputedField avec store=True a une vraie colonne : la valeur est recalculée à chaque écriture par le noyau, et elle est donc triable et cherchable en SQL. N’y affectez jamais de valeur à la main : le recalcul l’écrase au moment du flush.

Avec store=False, il n’y a pas de colonne : la valeur est recalculée à chaque lecture, et elle est invisible pour une requête SQL.

La relation

Link(name="title_id", target_model="library_title", nullable=False)

Rassembler les déclarations

models/__init__.py les appelle dans l’ordre des dépendances : un exemplaire pointe vers une œuvre, un emprunt vers un exemplaire et un adhérent.

def register_models(registry: Registry) -> None:
"""Declare every model of the module, dependencies first."""
register_title(registry)
register_copy(registry)
register_member(registry)
register_loan(registry)
register_loan_workflow(registry)

Des fichiers aux tables

Fenêtre de terminal
kewos reconcile --confirm --modules-root ./modules
main [active]: reconciled (0 migration(s), 1 column(s))
done: 1 tenant(s), 0 failed

Les quatre tables existent :

library_copy library_loan library_member library_title

Et les colonnes sont plus nombreuses que ce que nous avons écrit :

acquired_on barcode condition created_at created_by_id deleted_at
deleted_by_id id kws_custom_data name shelf title_id updated_at
updated_by_id version

Ce que la base tient, et que le code ne peut pas tenir

unique=True porte sur une colonne. Deux règles fréquentes en gestion ne tiennent pas dans une colonne.

« Le même exemplaire ne sort pas deux fois le même jour au même adhérent. » C’est une unicité sur trois colonnes ensemble :

registry.register_model(
"library_loan",
module="library",
fields=[...],
unique_together=(("copy_id", "member_id", "taken_on"),),
check_constraints=(("due_after_taken", "due_on >= taken_on"),),
)

unique_together prend une liste de combinaisons (on peut en déclarer plusieurs). check_constraints prend des couples (nom, expression SQL) : l’expression part telle quelle dans la base, donc restez sur du SQL standard si vous voulez rester portable.

La différence avec une règle métier

L’échéance qui suit la sortie est exactement l’invariant de la page Les règles métier. Écrit deux fois, à deux endroits, exprès :

Une règle métierUne contrainte de base
S’exécutedans la session ORMdans la base
Attrapeles écritures qui passent par le noyautoutes les écritures, y compris un psql
Répondun message avec le champ fautifune erreur de contrainte
Sert àexpliquer à l’utilisateurgarantir que la donnée est vraie

La règle donne le bon message ; la contrainte donne la garantie. Une donnée « toujours vraie » qui n’est vraie que quand l’application est le seul chemin n’est pas toujours vraie : un import, un script de reprise, une correction faite à la main un vendredi soir passent à côté.

Vu depuis un terminal, sur une base réelle :

ERROR: new row for relation "library_loan" violates check constraint "due_after_taken"
DETAIL: Failing row contains (18, 5, 1, 2026-09-10, 2026-09-01, ...)

Et par l’API, le doublon :

HTTP/1.1 409 Conflict
{"detail":"a record with these values already exists"}

Ce que le noyau ajoute tout seul

ChampRôle
idLa clé primaire.
created_at, created_by_idQui a créé, et quand.
updated_at, updated_by_idQui a modifié en dernier, et quand.
versionUn compteur d’écritures, qui sert à détecter deux modifications concurrentes.
deleted_at, deleted_by_idLa suppression douce : l’enregistrement disparaît des listes sans quitter la base.
kws_custom_dataLes champs ajoutés à chaud par le Builder.

On ne les déclare pas, on ne les redéfinit pas, et on peut compter dessus dans n’importe quel modèle.

L’API, sans l’avoir écrite

Fenêtre de terminal
curl -X POST http://127.0.0.1:8080/api/v1/library_title \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Dune","author":"Frank Herbert","isbn":"9780441013593",
"published_year":1965,"genre":"fiction"}'
{"id":1,"name":"Dune","author":"Frank Herbert","isbn":"9780441013593",
"published_year":1965,"genre":"fiction","summary":null,
"created_at":"2026-09-03T18:24:11.402881+00:00","created_by_id":1,
"updated_at":"2026-09-03T18:24:11.402881+00:00","updated_by_id":1,
"version":1,"deleted_at":null,"deleted_by_id":null}

Et ensuite

Les écrans.