ORM Basics#
Asok has a built-in SQLite ORM. Zero config — db.sqlite3 is created automatically. Tables are auto-created from your model definitions on app start.
Define a model#
# src/models/post.py
from asok import Model, Field, Relation
class Post(Model):
title = Field.String()
body = Field.String()
slug = Field.Slug(populate_from='title')
published = Field.Boolean(default=False)
author_id = Field.ForeignKey(lambda: User)
cover = Field.File(upload_to='posts')
created_at = Field.CreatedAt()
updated_at = Field.UpdatedAt()
deleted_at = Field.SoftDelete()
Custom table name#
By default, the table name is the pluralized model name (Post → posts, Category → categories). To override:
class Category(Model):
__tablename__ = "categories" # Explicit table name
name = Field.String()
Field types#
| Field | SQLite | Notes |
|---|---|---|
Field.String(max_length=255) | TEXT | Short text — <input type="text"> in admin / Form.from_model(). max_length defaults to 255 |
Field.Text(wysiwyg=False) | TEXT | Long text — <textarea> in admin. Pass wysiwyg=True for rich text editor. |
Field.Slug(populate_from="title", always_update=False) | TEXT | URL-friendly string. Set always_update=True to regenerate it every time the source field changes. |
Field.Email(max_length=255) | TEXT | Validated email — <input type="email"> and rejected on save if invalid |
Field.Integer() | INTEGER | |
Field.Float(precision=2) | REAL | precision controls form input step (e.g. step="0.01"). Alias: Field.Double() |
Field.Boolean() | INTEGER | 0/1 — rendered as <input type="checkbox"> |
Field.Date() | TEXT | ISO format |
Field.DateTime() | TEXT | ISO format |
Field.Password() | TEXT | Auto-hashed (PBKDF2-SHA256, 100k) |
Field.ForeignKey(Model) | INTEGER | FK to another model. Use dropdown=True for rich select in forms. |
Field.Dropdown(choices) | TEXT | Fixed choices — renders as a premium searchable dropdown. |
Field.File(upload_to='dir') | TEXT | Stores filename, files saved under uploads/ |
Field.CreatedAt() | TEXT | Set once on first save |
Field.UpdatedAt() | TEXT | Updated on every save |
Field.SoftDelete() | TEXT | Enables soft delete (see below) |
Field.Dropdown(choices) | TEXT | List of tuples (value, label). |
Rich Dropdowns and Selection#
You can enable premium, searchable dropdowns directly from the model definition. These are automatically picked up by Form.from_model().
Fixed choices with Field.Dropdown#
class Ticket(Model):
status = Field.Dropdown(
label="Ticket Status",
choices=[("open", "Open"), ("pending", "Pending"), ("closed", "Closed")],
searchable=True
)
Relationships with Field.ForeignKey#
Enable dropdown=True to replace the standard <select> with a rich searchable UI:
class Post(Model):
category_id = Field.ForeignKey(
Category,
dropdown=True,
dropdown_title="name", # Field to use as title
dropdown_subtitle="desc", # Optional subtitle field
dropdown_image="icon_url", # Optional image/avatar field
dropdown_searchable=True
)
Choosing the right text field#
Three text-flavored fields to pick from depending on intent — they all map to SQLite TEXT but they affect form rendering and validation:
class Contact(Model):
name = Field.String(max_length=100) # short text, <input type="text">
email = Field.Email() # email input + auto-validation
message = Field.Text() # long text, <textarea>
Field.Email() validates the value on save() (and on Model.create()) using a basic regex. Invalid emails raise ModelError:
Contact.create(email='not-an-email', message='hi')
# → ModelError: Email is not a valid email address.
This validation happens regardless of whether the value comes from a Form, an API call, or hand-written code. Forms generated via Form.from_model() also pick up the email validation rule automatically (see Forms).
Field options#
Field.String(max_length=80, default='draft', unique=True, nullable=False)
All fields accept default, unique, and nullable. String and Email additionally accept max_length. Float accepts precision.
Labels, rules and custom error messages#
Fields can define labels, validation rules, and custom error messages that are automatically used when generating forms with Form.from_model():
class Contact(Model):
__tablename__ = "contacts"
name = Field.String(
max_length=100,
nullable=False,
label="Full Name", # Custom label (default: "Name")
rules="min:4|alpha_spaces", # Custom validation rules
messages={
"required": "Please enter your full name",
"min": "Name must be at least 4 characters",
"max": "Name cannot exceed 100 characters",
"alpha_spaces": "Name can only contain letters and spaces"
}
)
email = Field.Email(
max_length=100,
nullable=False,
label="Email Address",
messages={
"required": "Email is required to contact you",
"email": "Please provide a valid email address"
}
)
message = Field.Text(
nullable=False,
label="Your Message",
rules="min:10",
messages={
"required": "Please tell us what you want to say",
"min": "Message must be at least 10 characters"
}
)
When you generate a form from this model:
form = Form.from_model(Contact, request)
The form will: - Use "Full Name" as the label instead of "Name" - Apply all validation rules (auto-generated + custom) - Display your custom error messages when validation fails
How rules are combined:
Asok automatically combines: 1. Auto-generated rules based on field type and constraints: - required (if nullable=False) - max:N (if max_length=N) - email (if Field.Email()) - tel (if Field.Tel()) - etc.
- Your custom rules (via the
rulesparameter)
For example, the name field above will have these combined rules:
required|max:100|min:4|alpha_spaces
CRUD#
# Create
post = Post.create(title='Hello', body='...')
# Read
Post.find(id=1)
Post.find(slug='hello')
Post.all()
Post.all(published=True, order_by='-created_at', limit=10)
Post.count(published=True)
Post.exists(slug='hello')
# Update
post.title = 'Updated'
post.save()
# Or set + save in one call (perfect with form.data)
post.update(title='Updated', body='...')
# Delete
post.delete()
Find-or-create helpers#
Post.first_or_create(slug='hello', defaults={'title': 'Hello', 'body': '...'})
Post.update_or_create(slug='hello', defaults={'title': 'Updated'})
Query builder#
For anything more complex than equality filters, use the chainable Query builder:
Post.query() \
.where('views', '>', 100) \
.where('published', True) \
.order_by('-created_at') \
.limit(20) \
.get()
Post.where('title', 'LIKE', '%python%').get()
Post.where_in('id', [1, 2, 3]).get()
Post.like('title', '%python%').get() # Shortcut for LIKE
Methods#
| Method | Description |
|---|---|
.where(col, op, val) | Add a WHERE clause (=, !=, <, >, <=, >=, LIKE, NOT LIKE, IN, NOT IN) |
.where(col, val) | Shortcut for equality |
.where_in(col, [vals]) | WHERE col IN (...) |
.like(col, pattern) | Shortcut for LIKE |
.order_by('col') / .order_by('-col') | ASC / DESC |
.limit(n) / .offset(n) | Pagination |
.with_('relation', ...) | Eager-load relations (avoids N+1) |
.get() | Execute, return list |
.first() | Execute, return first or None |
.count() | Aggregate count |
.sum('col') | Sum values in column |
.avg('col') | Average value |
.min('col') | Minimum value |
.max('col') | Maximum value |
.pluck('col') | List of single column values |
.update(**vals) | Bulk UPDATE |
.delete() | Bulk DELETE |
.exists() | Bool |
Raw SQL#
Post.raw("SELECT * FROM posts WHERE views > ?", [100])
Relationships#
class User(Model):
name = Field.String()
posts = Relation.HasMany('Post')
profile = Relation.HasOne('Profile')
roles = Relation.BelongsToMany('Role')
class Post(Model):
author_id = Field.ForeignKey(lambda: User)
author = Relation.BelongsTo('User')
| Relation | Returns | Default FK |
|---|---|---|
HasMany('Post') | list | <owner>_id on the target |
HasOne('Profile') | single or None | <owner>_id on the target |
BelongsTo('User') | single or None | <target>_id on self |
BelongsToMany('Role') | list | pivot table <a>_<b> (alphabetical) |
Access them as properties (not methods):
user = User.find(id=1)
user.posts # list of Post (property, no parentheses)
user.profile # Profile or None
user.roles # list of Role
post.author # User or None
Custom keys#
Relation.HasMany('Post', foreign_key='writer_id')
Relation.BelongsToMany('Tag', pivot_table='post_tags', pivot_fk='post_id', pivot_other_fk='tag_id')
Eager loading#
Avoid N+1 queries by pre-loading relations:
posts = Post.query().with_('author').limit(20).get()
for p in posts:
p.author # served from cache, no extra query
BelongsToMany: attach / detach / sync#
post.attach('tags', [1, 2, 3]) # add pivot rows
post.detach('tags', [2]) # remove specific
post.detach('tags') # remove all
post.sync('tags', [4, 5]) # replace all with these ids
Soft delete#
Add Field.SoftDelete() to enable it on a model:
class Post(Model):
title = Field.String()
deleted_at = Field.SoftDelete()
Now delete() only sets deleted_at. Soft-deleted rows are excluded from all queries by default.
post.delete() # soft delete
post.restore() # un-delete
post.force_delete() # permanent
Post.with_trashed().get() # include deleted
Post.only_trashed().get() # only deleted
File fields#
class Post(Model):
cover = Field.File(upload_to='posts')
The column stores the filename. The actual file is saved to uploads/posts/<filename> when set via a form. Use request.files and UploadedFile.save() for manual handling — see File Storage.
Lifecycle hooks#
Override any of these on your model:
class Post(Model):
def before_save(self): ...
def after_save(self): ...
def before_create(self): ...
def after_create(self): ...
def before_update(self): ...
def after_update(self): ...
def before_delete(self): ...
def after_delete(self): ...
Pagination#
result = Post.paginate(page=2, per_page=10, order_by='-created_at', published=True)
result['items'] # list of Post
result['total'] # total count
result['pages'] # total pages
result['current_page'] # 2
Password hashing#
Field.Password() auto-hashes on save (PBKDF2-SHA256, 100k iterations):
class User(Model):
email = Field.String(unique=True)
password = Field.Password()
user = User.create(email='a@b.com', password='secret')
user.check_password('password', 'secret') # True
Slug auto-generation#
slug = Field.Slug(populate_from='title')
# "Hello World!" → "hello-world"
Generated on save if empty.
Serialization#
post.to_dict()
# {"id": 1, "title": "Hello", ...}
For controlled API output, use Schema (see Serialization):
from asok import Schema, Field
class PostSchema(Schema):
title = Field.String()
slug = Field.String()
PostSchema().dump(post)
PostSchema(many=True).dump(posts)
Performance#
- Thread-local connections — reused within the same thread
- WAL mode — concurrent reads while writing
- Eager loading —
.with_(...)batches related queries - Auto-indexes —
unique=TrueandForeignKeyget indexed automatically - No configuration needed
Was this page helpful?