Fields¶
The Field() function configures both Pydantic validation and database schema.
Basic Usage¶
from oxyde import Model, Field
class User(Model):
id: int | None = Field(default=None, db_pk=True)
name: str = Field(max_length=100)
email: str = Field(db_unique=True, db_index=True)
class Meta:
is_table = True
Field() Parameters¶
Database Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
db_pk |
bool |
False |
Primary key |
db_index |
bool |
False |
Create index |
db_index_name |
str |
Auto | Custom index name |
db_index_method |
str |
None | Index method: btree, hash, gin, gist |
db_unique |
bool |
False |
UNIQUE constraint |
db_nullable |
bool |
None | Override NULL/NOT NULL (None = infer from type) |
db_column |
str |
Field name | Database column name |
db_type |
str |
Auto | SQL type override |
db_default |
str |
None | SQL DEFAULT expression |
db_comment |
str |
None | Column comment |
Foreign Key Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
db_fk |
str |
PK of related model | Target field for FK |
db_on_delete |
str |
"RESTRICT" |
ON DELETE action |
db_on_update |
str |
"CASCADE" |
ON UPDATE action |
Relation Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
db_reverse_fk |
str |
None | Reverse FK field name |
db_m2m |
bool |
False |
Many-to-many relation |
db_through |
str |
None | M2M junction table |
Pydantic Parameters¶
All standard Pydantic Field() parameters work:
| Parameter | Type | Description |
|---|---|---|
default |
Any | Default value |
default_factory |
Callable | Factory for default value |
alias |
str |
JSON key name |
description |
str |
Field description |
ge, gt, le, lt |
Number | Numeric bounds |
min_length, max_length |
int |
String length bounds |
pattern |
str |
Regex pattern |
Primary Key¶
# Auto-increment integer
id: int | None = Field(default=None, db_pk=True)
# UUID
from uuid import UUID, uuid4
id: UUID = Field(default_factory=uuid4, db_pk=True)
# Custom type
id: int = Field(db_pk=True, db_type="BIGSERIAL")
Indexes¶
# Simple index
email: str = Field(db_index=True)
# Unique index
username: str = Field(db_unique=True)
# Custom index name
email: str = Field(db_index=True, db_index_name="ix_users_email")
# Index method (PostgreSQL)
data: dict = Field(db_type="JSONB", db_index=True, db_index_method="gin")
SQL Defaults¶
# Timestamp
created_at: datetime = Field(db_default="CURRENT_TIMESTAMP")
# PostgreSQL functions
uuid: str = Field(db_default="gen_random_uuid()")
# String literal (note the quotes)
status: str = Field(db_default="'pending'")
# Numeric
count: int = Field(db_default="0")
Python vs SQL Default
default is used when creating Python objects.
db_default is used by the database when inserting rows.
Column Mapping¶
# Different Python name and DB column
created_at: datetime = Field(db_column="created_timestamp")
# With JSON alias too
created_at: datetime = Field(
alias="createdAt", # JSON API uses camelCase
db_column="created_timestamp" # DB uses snake_case
)
Custom SQL Types¶
# Override inferred type
name: str = Field(db_type="VARCHAR(255)")
# PostgreSQL-specific
data: dict = Field(db_type="JSONB")
tags: list[str] = Field(db_type="TEXT[]")
# MySQL-specific
content: str = Field(db_type="LONGTEXT")
Array Inner Constraints¶
For PostgreSQL array columns the inner element type can carry its own constraints via Annotated:
from typing import Annotated
from decimal import Decimal
tags: list[Annotated[str, Field(max_length=50)]] | None = Field(default=None)
# PostgreSQL: VARCHAR(50)[]
prices: list[Annotated[Decimal, Field(max_digits=10, decimal_places=2)]] | None = Field(default=None)
# PostgreSQL: NUMERIC(10,2)[]
On MySQL and SQLite arrays fall back to JSON / TEXT, so inner constraints only affect DDL on PostgreSQL.
Enum Fields¶
A field annotated with a string-valued Enum becomes a database enum column:
from enum import Enum
from oxyde import Model, Field
class PostStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class Post(Model):
id: int | None = Field(default=None, db_pk=True)
title: str
status: PostStatus = Field(default=PostStatus.DRAFT)
class Meta:
is_table = True
Both enum members and their raw string values work in queries:
await Post.objects.filter(status=PostStatus.PUBLISHED).all()
await Post.objects.filter(status="published").all() # same thing
await Post.objects.filter(id=1).update(status=PostStatus.ARCHIVED)
Only the values must be strings — subclassing str is idiomatic but not required.
Per-Dialect Behavior¶
| PostgreSQL | MySQL | SQLite | |
|---|---|---|---|
| Column type | native type, CREATE TYPE "post_status_enum" AS ENUM (...) |
inline ENUM('draft','published','archived') |
TEXT |
| Query parameters | cast to the type: $1::"post_status_enum" |
plain string | plain string |
list[PostStatus] |
"post_status_enum"[] |
JSON |
TEXT (JSON-encoded) |
| Database-level enforcement | yes | yes | none |
ORDER BY on the column |
declaration order | declaration order | lexicographic |
Type Naming and Collisions¶
The enum type name is always derived from the class name: PostStatus → post_status_enum. It cannot be chosen explicitly.
Two enum classes in different modules that produce the same name (e.g. two Status classes) share one database type:
- identical value sets — the type is shared, no error;
- different value sets —
makemigrationsfails withenum type 'status_enum' has conflicting value sets.
Rename one of the classes, or opt one of them out of the enum machinery with db_type (see below).
db_type on Enum Fields¶
Setting any db_type on an enum field turns it into a plain column with that verbatim storage type and disables the whole enum machinery — no CREATE TYPE, no parameter casts, no tracking of added values:
# Stored as TEXT everywhere; Pydantic still validates the value.
status: PostStatus = Field(default=PostStatus.DRAFT, db_type="TEXT")
db_type never names an enum type — the native type name is always the auto-generated one.
Validation¶
- Values are validated by Pydantic on the model and on
update()— an unknown value is rejected before reaching the database. -
A non-string enum (
IntEnum, or any member whose value is not astr) raisesTypeErrorat model definition time:
SQLite has no enum enforcement
On SQLite the column is plain TEXT and no CHECK constraint is generated — a raw SQL write can store any string. Validation happens in Pydantic only. Sorting also differs: ORDER BY status follows declaration order on PostgreSQL/MySQL but is lexicographic on SQLite.
See Migrations — Enum Migrations for how enum changes are applied.
Foreign Keys¶
Foreign keys are defined by type annotation:
class Post(Model):
id: int | None = Field(default=None, db_pk=True)
title: str
author: Author | None = Field(default=None, db_on_delete="CASCADE") # FK to Author
class Meta:
is_table = True
FK to Non-PK Field¶
# FK to Author.uuid instead of Author.id
author: Author | None = Field(
default=None,
db_fk="uuid", # Target the uuid field
db_on_delete="CASCADE"
)
# Creates author_uuid column
ON DELETE Actions¶
| Action | Description |
|---|---|
CASCADE |
Delete related rows |
SET NULL |
Set FK to NULL (requires nullable field) |
RESTRICT |
Prevent deletion if references exist |
NO ACTION |
Same as RESTRICT (deferred) |
# CASCADE - delete posts when author is deleted
author: Author | None = Field(default=None, db_on_delete="CASCADE")
# SET NULL - set author_id to NULL when author is deleted
author: Author | None = Field(default=None, db_on_delete="SET NULL")
# RESTRICT - prevent author deletion if posts exist
author: Author | None = Field(default=None, db_on_delete="RESTRICT")
Relations¶
Reverse Foreign Key¶
Define on the "one" side of a one-to-many relationship:
class Author(Model):
id: int | None = Field(default=None, db_pk=True)
name: str
posts: list["Post"] = Field(db_reverse_fk="author") # Virtual field - not stored in DB
class Meta:
is_table = True
Use with prefetch():
authors = await Author.objects.prefetch("posts").all()
for author in authors:
print(f"{author.name} has {len(author.posts)} posts")
Many-to-Many¶
class Post(Model):
id: int | None = Field(default=None, db_pk=True)
title: str
tags: list["Tag"] = Field(db_m2m=True, db_through="PostTag")
class Meta:
is_table = True
class Tag(Model):
id: int | None = Field(default=None, db_pk=True)
name: str = Field(db_unique=True)
class Meta:
is_table = True
class PostTag(Model):
id: int | None = Field(default=None, db_pk=True)
post: Post | None = Field(default=None, db_on_delete="CASCADE")
tag: Tag | None = Field(default=None, db_on_delete="CASCADE")
class Meta:
is_table = True
Pydantic Validation¶
Numeric Bounds¶
age: int = Field(ge=0, le=150) # 0 <= age <= 150
price: float = Field(gt=0) # price > 0
quantity: int = Field(ge=1, le=100) # 1 <= quantity <= 100
Decimal Precision¶
Control the SQL type for Decimal fields:
from decimal import Decimal
price: Decimal = Field(max_digits=10, decimal_places=2)
# PostgreSQL: NUMERIC(10,2)
# MySQL: DECIMAL(10,2)
These constraints are tracked by migrations — changing max_digits or decimal_places generates an ALTER COLUMN.
String Validation¶
name: str = Field(min_length=1, max_length=100)
email: str = Field(pattern=r"^[\w.-]+@[\w.-]+\.\w+$")
Required vs Optional¶
# Required - no default
name: str
# Optional with None default
bio: str | None = Field(default=None)
# Optional with value default
status: str = Field(default="active")
# Required but can be None
data: str | None # Must be passed, but can be None
Computed Fields¶
Pydantic @computed_field is supported on models and is automatically excluded from INSERT / UPDATE statements — computed values live only on the instance.
from pydantic import computed_field
class Product(Model):
id: int | None = Field(default=None, db_pk=True)
price: float = Field(default=0.0)
quantity: int = Field(default=1)
@computed_field
@property
def total(self) -> float:
return self.price * self.quantity
class Meta:
is_table = True
total is available on loaded instances but is never sent to the database and is not tracked by migrations.
Comments¶
Add SQL comments to columns:
Complete Example¶
from datetime import datetime
from decimal import Decimal
from uuid import UUID, uuid4
from oxyde import Model, Field
class Product(Model):
# Primary key
id: UUID = Field(default_factory=uuid4, db_pk=True)
# Required fields
name: str = Field(min_length=1, max_length=200)
price: Decimal = Field(ge=0, db_type="NUMERIC(10, 2)")
# Optional fields
description: str | None = Field(default=None)
sku: str | None = Field(default=None, db_unique=True, db_index=True)
# With defaults
active: bool = Field(default=True)
stock: int = Field(default=0, ge=0)
# SQL defaults
created_at: datetime = Field(db_default="CURRENT_TIMESTAMP")
updated_at: datetime | None = Field(default=None)
# Foreign key
category: Category | None = Field(default=None, db_on_delete="SET NULL")
class Meta:
is_table = True
table_name = "products"