Models and fields
Declare SQLAlchemy-backed tables with model.Model and typed fields, then evolve them through migrations.
Work through each step in order. The examples use the generated LenAPI project structure and are designed to be adapted to your app.
Import model helpers and define a model
Import the model namespace, then subclass model.Model. Model.Model is the SQLAlchemy-backed base class: each subclass maps to a table named after the class in lowercase, and gets an integer id unless you declare another primary key. Fields such as model.StringField are column declarations.
from lenapi import model
class Classroom(model.Model):
name = model.StringField(max_length=120, unique=True)
class Student(model.Model):
person_name = model.StringField(max_length=120, unique=True)
course = model.StringField(max_length=120)
classroom_id = model.ForeignKeyField(
"classroom.id", nullable=True, model=model.null
)Choose appropriate field types
Fields include AutoField, IntegerField, PositiveIntegerField, BigIntegerField/BigIntField, PositiveBigIntegerField, StringField, TextField, EmailField, PhoneField, IPField, URLField, BooleanField, FloatField, DecimalField, DateField, TimeField, DateTimeField, DurationField, JSONField/Json, UUIDField, ImageField, FileField, FileImageField, ForeignKeyField, OneToOneField, and ManyToManyField. Most fields default to nullable=True and blank=True; BooleanField defaults to True. JSONField stores JSON-compatible dictionaries, lists, strings, numbers, booleans, or null. Use a factory such as default=dict for mutable defaults.
class ExamResult(model.Model):
score = model.DecimalField(max_digits=6, decimal_places=2)
details = model.Json(default=dict)
is_published = model.BooleanField(default=False, nullable=False)
result = ExamResult.create(
score=92.50,
details={"course": "Python", "attempt": 1},
is_published=True,
)LenAPI validates values for EmailField, PhoneField, IPField, URLField, UUIDField, and positive integer fields when a model is saved. Keep Pydantic request schemas as the API boundary: they validate and document request input before database logic runs.
Validate model values and customize errors
Email, international phone number, IP, URL, UUID, and nonnegative integer fields are validated when save() or asave() is called. A validation failure raises HTTP 422. Phone values must start with + and contain no more than 18 characters. IPField accepts IPv4 and IPv6. Customize a validation message with error_messages; unique is available for unique columns.
class Customer(model.Model):
email = model.EmailField(
unique=True,
error_messages={
"email": "This is not an email",
"unique": "An account already uses this email",
},
)
phone = model.PhoneField(max_length=18)
ip_address = model.IPField()
website = model.URLField()
age = model.PositiveIntegerField(nullable=False)
public_id = model.UUIDField()
# Invalid values raise HTTPException(422, ...).
# Positive* fields also have a database CHECK constraint, protecting direct SQL writes.Use date, time, and duration fields
DateField, TimeField, and DateTimeField default to the current UTC date/time when no explicit default is supplied. Set auto_add_now=False to opt out. DurationField stores a fixed datetime.timedelta; calendar months and years need calendar-aware date arithmetic.
from datetime import datetime, timezone
from lenapi import model
class Subscription(model.Model):
started_at = model.DateTimeField()
started_on = model.DateField()
reminder_at = model.TimeField()
trial_length = model.DurationField(weeks=1)
subscription = Subscription.create()
expires_at = datetime.now(timezone.utc) + subscription.trial_length
# weeks=1 stores datetime.timedelta(days=7), then add it to a start time.Use unique fields and unique lookups
Set unique=True on a field when the database must reject duplicate values. LenAPI assigns a stable constraint name automatically, so an explicit name is not required. Model.get.unique('field') is a lookup projection that returns only the chosen field; it does not create a database constraint. Use a unique field such as username or email when you need the database to guarantee one matching record.
from lenapi import model
class Student(model.Model):
username = model.StringField(max_length=120, unique=True)
person_name = model.StringField(max_length=120)
# Full row: get() returns the one matching model instance, or None.
student = Student.get(username="ada").get()
# Project only person_name; this is a query projection.
name = Student.get.unique("person_name").get(username="ada")For an explicitly named SQL constraint, use model.UniqueConstraint in __table_args__ and omit unique=True on that field. Explicit names are optional.
Name database constraints
Constraint names are optional in model declarations. LenAPI generates stable names for unique and foreign-key constraints, so ordinary unique=True and ForeignKeyField declarations work without a name, including during SQLite batch migrations. Set __table_args__ or ForeignKeyField.constraint_name only when you need to choose the exact database name; omit unique=True on a field when declaring its explicit UniqueConstraint.
class Student(model.Model):
# No explicit constraint name is needed here.
phone = model.PhoneField(max_length=32, nullable=False, unique=True)
# To choose the exact unique-constraint name instead:
class StudentWithNamedPhone(model.Model):
phone = model.PhoneField(max_length=32, nullable=False)
__table_args__ = (
model.UniqueConstraint("phone", name="uq_student_phone"),
)
class Enrollment(model.Model):
student_id = model.ForeignKeyField(
"student.id",
# Optional: set this only when you want an exact foreign-key name.
constraint_name="fk_enrollment_student_id_student",
)Set delete behavior on foreign keys
Choose one policy for related records: cascade deletes children, null keeps children with a NULL foreign key, and protected blocks deletion while a child refers to the row.
class Verification(model.Model):
user_id = model.ForeignKeyField("user.id", model=model.cascade.delete)
class AuditLog(model.Model):
user_id = model.ForeignKeyField(
"user.id", nullable=True, model=model.null
)
class Invoice(model.Model):
user_id = model.ForeignKeyField("user.id", model=model.protected)Define one-to-one and one-to-many relationships
One-to-one and one-to-many both use foreign keys, but their cardinality differs. OneToOneField places a unique foreign key on the dependent model, so each learner has at most one profile. For one-to-many, put a regular ForeignKeyField on the many-side: each learner points to one classroom, while a classroom can have many learners. There is no separate OneToManyField. Use special() to resolve a foreign key from one loaded row; use filter() to fetch the many-side rows.
class Classroom(model.Model):
name = model.StringField(max_length=120)
class Learner(model.Model):
classroom_id = model.ForeignKeyField(
"classroom.id", nullable=True, model=model.null
)
class LearnerProfile(model.Model):
learner_id = model.OneToOneField(
"learner.id", nullable=False, model=model.CASCADE
)
biography = model.TextField(default="")
# Foreign key: resolve one learner's classroom (sync).
learner = Learner.get(id=1).special("classroom_id")
classroom = learner.classroom_id # Classroom instance, or None
# One-to-many: list all learners assigned to the classroom.
learners = Learner.filter(classroom_id=classroom.id).all()
# One-to-one: fetch the learner's single profile.
profile = LearnerProfile.get(learner_id=learner.id).get()
# Async one-to-many and one-to-one queries.
learners = await Learner.afilter(classroom_id=classroom_id).all()
profile = await LearnerProfile.aget(learner_id=learner_id)OneToOneField creates a unique constraint automatically. The foreign key uses SET NULL here, so classroom_id must be nullable. Run python lenora.py migration after changing these models.
Connect many-to-many models
ManyToManyField creates an association table included in Alembic migrations. Add related instances, save the owner, and query the collection.
class Course(model.Model):
name = model.StringField(max_length=120)
class Teacher(model.Model):
name = model.StringField(max_length=120)
courses = model.ManyToManyField("Course")
teacher = Teacher.get_or_404(id=1)
teacher.courses.add(Course.get_or_404(id=1))
teacher.save()