Skip to content

2. The data

The work

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")
)

Three things are worth noticing.

unique=True on the ISBN puts a constraint in the schema, not a check in a form: two processes inserting the same ISBN at the same time cannot both get through.

taggable=True wires the model into the kernel’s tag system. No table to create, no field to declare.

register_searchable makes it show up in global search, with a title and a subtitle. A business search is never hand-written in a module.

The field types

TypeFor
CharFieldText. With max_length for one line, without for a long text.
IntegerField, BigIntegerFieldIntegers.
DecimalFieldEvery amount and every quantity. precision and scale required.
BooleanFieldYes or no.
DateField, DateTimeFieldA date, an instant. Instants are always UTC.
SelectionFieldA closed list of values.
JsonField, RichTextFieldA structure, rich content.
FileField, BinaryFieldAn attachment, binary content.
Link, Backlink, LinkSetThe relations.
ComputedFieldA value derived from a computation.
RelatedFieldA shortcut to a field on a relation.
AggregateFieldA sum or a count over a relation’s children.

The copy, and the field everybody forgets

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,
),
],
)

A ComputedField with store=True has a real column: the value is recomputed on every write by the kernel, so it is sortable and searchable in SQL. Never assign to it by hand: the recomputation overwrites it at flush time.

With store=False there is no column: the value is recomputed on every read, and it is invisible to a SQL query.

The relation

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

Gathering the declarations

models/__init__.py calls them in dependency order: a copy points at a work, a loan at a copy and a member.

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)

From files to 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

The four tables exist:

library_copy library_loan library_member library_title

And there are more columns than you wrote:

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

What the database holds, and the code cannot

unique=True covers one column. Two rules that come up constantly in business software do not fit in a column.

“The same copy does not go out twice on the same day to the same member.” That is uniqueness over three columns together:

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 takes a list of combinations (declare several if you need them). check_constraints takes (name, SQL expression) pairs: the expression goes to the database verbatim, so keep to standard SQL if you want to stay portable.

The difference from a business rule

The due date following the loan is exactly the invariant from Business rules. Written twice, in two places, on purpose:

A business ruleA database constraint
Runsin the ORM sessionin the database
Catcheswrites that go through the kernelevery write, a psql included
Answersa message naming the offending fielda constraint error
Is forexplaining it to a userguaranteeing the data is true

The rule gives the good message; the constraint gives the guarantee. Data that is “always true” only while the application is the only path is not always true: an import, a migration script, a fix typed by hand on a Friday evening all go around it.

Seen from a terminal, on a real database:

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, ...)

And through the API, the duplicate:

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

What the kernel adds on its own

FieldRole
idThe primary key.
created_at, created_by_idWho created it, and when.
updated_at, updated_by_idWho last changed it, and when.
versionA write counter, used to detect two concurrent changes.
deleted_at, deleted_by_idSoft delete: the record leaves the lists without leaving the database.
kws_custom_dataFields added on the fly by Builder.

You do not declare them, you do not redefine them, and you can rely on them in any model.

The API, without writing it

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}

Next

The screens.