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, SelectionFieldfrom 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") )models/library_title.yaml :
model: library_titlelabel: model.library_titlefields: name: { type: char, required: true, max_length: 200 } author: { type: char, required: true, max_length: 120 } isbn: { type: char, max_length: 17, unique: true } published_year: { type: integer } genre: type: selection choices: [[fiction, "Fiction"], [science, "Science"], [history, "History"], [children, "Children"], [reference, "Reference"]] default: fiction summary: { type: text } cover: { type: file }options: taggable: true image_field: coversearch: title: name subtitle: authorDeux différences de vocabulaire, aucune de fond : required: true là où Python
écrit nullable=False, et type: text pour un texte long, là où Python utilise
un CharField sans longueur.
library.km :
model library_title "model.library_title" { taggable gallery cover
field name text required max 200 field author text required max 120 field isbn text unique max 17 field published_year number field genre choice fiction "Fiction" | science "Science" | history "History" | children "Children" | reference "Reference" default fiction field summary text field cover file extensions png, jpg, jpeg, webp, svg maxsize 5242880
search title from name subtitle from author}Le même modèle, en une phrase par champ. taggable et gallery cover sont ce que
le YAML écrit sous options, et search remplace le bloc du même nom.
Ce fichier ne décrit pas autre chose : il produit le même document que le YAML ci-contre, et c’est vérifié par un test. Voir Écrire en KM.
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
| Type | Pour |
|---|---|
CharField | Un texte. Avec max_length pour une ligne, sans pour un texte long. |
IntegerField, BigIntegerField | Des entiers. |
DecimalField | Tout montant et toute quantité. precision et scale obligatoires. |
BooleanField | Oui ou non. |
DateField, DateTimeField | Une date, un instant. Les instants sont toujours en UTC. |
SelectionField | Une liste fermée de valeurs. |
JsonField, RichTextField | Une structure, un contenu riche. |
FileField, BinaryField | Une pièce jointe, un contenu binaire. |
Link, Backlink, LinkSet | Les relations. |
ComputedField | Une valeur dérivée d’un calcul. |
RelatedField | Un raccourci vers un champ d’une relation. |
AggregateField | Une 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, ), ], )model: library_copylabel: model.library_copyfields: title_id: { type: many2one, target: library_title, required: true } barcode: { type: char, required: true, max_length: 32, unique: true } shelf: { type: char, max_length: 16 } condition: type: selection choices: [[new, "New"], [good, "Good"], [worn, "Worn"], [lost, "Lost"]] default: good acquired_on: { type: date } name: type: computed base_type: char compute: "concat(barcode, ' - ', shelf)"Le calcul est une expression, pas une fonction : un langage restreint, sur
liste blanche, sans eval ni échappatoire vers du code arbitraire. C’est plus
court, et c’est aussi moins puissant : une règle qui ne s’exprime pas ainsi
demande un module en code.
model library_copy "model.library_copy" { field title_id link to library_title required field barcode text required unique max 32 field shelf text max 16 field condition choice new "New" | good "Good" | worn "Worn" | lost "Lost" default good field acquired_on date
compute name text max 64 stored = concat(barcode, ' - ', shelf)}Le champ calculé s’écrit comme une phrase : un nom, un type, stored, puis ce qu’il
vaut. L’expression est celle du noyau, KM ne fait que la transporter.
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)title_id: { type: many2one, target: library_title, required: true }field title_id link to library_title required on_delete cascadelink to est la même relation que many2one : le format ajoute un mot lisible,
il ne change rien au lien.
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
kewos reconcile --confirm --modules-root ./modulesmain [active]: reconciled (0 migration(s), 1 column(s))done: 1 tenant(s), 0 failedLes quatre tables existent :
library_copy library_loan library_member library_titleEt les colonnes sont plus nombreuses que ce que nous avons écrit :
acquired_on barcode condition created_at created_by_id deleted_atdeleted_by_id id kws_custom_data name shelf title_id updated_atupdated_by_id versionCe 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étier | Une contrainte de base | |
|---|---|---|
| S’exécute | dans la session ORM | dans la base |
| Attrape | les écritures qui passent par le noyau | toutes les écritures, y compris un psql |
| Répond | un message avec le champ fautif | une erreur de contrainte |
| Sert à | expliquer à l’utilisateur | garantir 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
| Champ | Rôle |
|---|---|
id | La clé primaire. |
created_at, created_by_id | Qui a créé, et quand. |
updated_at, updated_by_id | Qui a modifié en dernier, et quand. |
version | Un compteur d’écritures, qui sert à détecter deux modifications concurrentes. |
deleted_at, deleted_by_id | La suppression douce : l’enregistrement disparaît des listes sans quitter la base. |
kws_custom_data | Les 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
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}