The field types sillo.record adds on top of Tortoise (AnyJSONField, PasswordField, the timestamp fields, SlugField and ULIDField) and exactly what each does and does not do.
Every Tortoise field type works:
IntField, CharField, TextField, JSONField, ForeignKeyField and the
rest. Record adds seven.
from sillo.record.fields import ( AnyJSONField, PasswordField, CreatedAtField, UpdatedAtField, SoftDeleteField, SlugField, ULIDField,)AnyJSONField
Section titled “AnyJSONField”from tortoise import fields
from sillo.record import AnyJSONField, Model
class Route(Model): policy = AnyJSONField()Tortoise’s JSONField treats a Python string as JSON that has already been
encoded. A bare string therefore fails unless the caller adds JSON quotes:
class TortoiseRoute(Model): policy = fields.JSONField()
await TortoiseRoute.create(policy="authenticated") # rejectedawait TortoiseRoute.create(policy='"authenticated"') # acceptedAnyJSONField treats strings as ordinary values, just as it treats dictionaries,
lists, numbers and booleans:
route = await Route.create(policy="authenticated")route.policy # "authenticated"A string that looks like encoded JSON remains a string. Use Tortoise’s standard
JSONField instead when callers intentionally provide serialized JSON:
route = await Route.create(policy='{"role":"admin"}')route.policy # '{"role":"admin"}', not {"role": "admin"}The database column type and JSON query operators are the same as JSONField.
PasswordField
Section titled “PasswordField”from sillo.record import Modelfrom sillo.record.fields import PasswordField
class Account(Model): email = fields.CharField(max_length=255, unique=True) password = PasswordField()A CharField that hashes on the way into the database:
account.password = "correct horse battery staple"await account.save()# stored: $2b$12$…Assigning plaintext through the ORM stores a hash. There is no separate step to forget, which is the point.
Verify with the helper, never by comparison:
from sillo.helpers.hashing import verify_password
if verify_password(submitted, account.password): ...An admin built on the model layer can detect a PasswordField and render a
password widget — reveal toggle, strength meter, confirmation — rather than a
text input. Warder does.
Two things to know
Section titled “Two things to know”It hashes with bcrypt. hash_password defaults to bcrypt, and this field
uses the default. Install the extra:
uv add "sillo-framework[hashing-bcrypt]"It recognises a hash from any configured scheme. Assigning a value that is already hashed stores it as-is; anything else is treated as a plaintext password and hashed. The check asks passlib which scheme produced the value, so argon2, scrypt and pbkdf2 hashes are all passed through, not just bcrypt.
from sillo.hashing import hash_password
account.password = hash_password(plaintext, scheme="argon2") # stored as givenaccount.password = plaintext # hashed for youThe timestamp fields
Section titled “The timestamp fields”Declared on the base model, so you rarely write them yourself.
| Field | Wraps | Behaviour |
|---|---|---|
CreatedAtField | DatetimeField(auto_now_add=True) | Set on insert, never updated |
UpdatedAtField | DatetimeField(auto_now=True) | Set on every save |
SoftDeleteField | DatetimeField(null=True, default=None) | None means active |
They are thin: each sets one Tortoise default and adds nothing else. The value
is that the intent is in the name. deleted_at = SoftDeleteField() says what a
nullable datetime is for.
Use them directly when a model needs a second one:
class Invoice(Model): approved_at = SoftDeleteField() # nullable datetime, defaults to NoneULIDField
Section titled “ULIDField”from sillo.record.fields import ULIDFieldfrom sillo.record.mixins import HasUlidMixin
class Event(Model, HasUlidMixin): id = ULIDField()A 26-character CharField, primary key by default. A
ULID sorts by creation time as a string, which
gives you a sortable identifier that does not leak a row count the way an
auto-increment integer does.
SlugField
Section titled “SlugField”class Post(Model): slug = SlugField(max_length=200)A CharField sized for a slug, with the intent in the name.
Generating the slug
Section titled “Generating the slug”Pass source_field and a row saved without a slug gets one from that
attribute:
class Post(Model): title = fields.CharField(max_length=200) slug = SlugField(source_field="title")
post = await Post.create(title="Hello World")post.slug # "hello-world"An explicitly assigned slug is kept. Generation only fills a blank, so editing the title later does not move a published URL.
Add unique=True and decide what happens on a collision, usually a numeric
suffix.
Writing your own
Section titled “Writing your own”Subclass a Tortoise field and override the two conversion hooks:
from tortoise import fields
class UpperCharField(fields.CharField): def to_db_value(self, value, instance, *args, **kwargs): return value.upper() if isinstance(value, str) else value
def to_python_value(self, value, *args, **kwargs): return valueFor converting values without a custom column type, casting is usually the lighter answer. It is configured per model rather than declared as a type.