Skip to content

Column types and mixins

sqlargon.types provides portable column types that compile to the best available construct per dialect, so the same model definition runs on PostgreSQL, SQLite and MySQL.

UUIDs

from uuid import UUID

from sqlalchemy.orm import Mapped, mapped_column
from uuid_utils.compat import uuid4

from sqlargon import Base
from sqlargon.types import GUID, GenerateUUID, GenerateUUIDV7


class User(Base):
    id: Mapped[UUID] = mapped_column(
        GUID(),
        primary_key=True,
        default=uuid4,                    # client-side default
        server_default=GenerateUUID(),    # server-side default
        nullable=False,
    )

GUID stores a native UUID on PostgreSQL and CHAR(36) elsewhere, and always returns uuid.UUID instances. Strings are accepted when binding.

GenerateUUID and GenerateUUIDV7 are SQL function elements meant for server_default:

Element PostgreSQL MySQL SQLite
GenerateUUID GEN_RANDOM_UUID() RANDOM_BYTES-based v4 expression randomblob-based v4 expression
GenerateUUIDV7 uuidv7() NOW(3) + RANDOM_BYTES v7 expression same v4 expression as above

uuidv7() requires a PostgreSQL server that ships the function (18+, or an extension), and the MySQL v7 expression requires MySQL 5.6.4+. SQLite has no UUID v7 equivalent — it falls back to the random v4 expression, so rely on the client-side default=uuid7 there if ordering matters.

Timestamps

from datetime import datetime

from sqlargon.types import Timestamp, now


class Event(Base):
    created_at: Mapped[datetime] = mapped_column(
        Timestamp(), server_default=now(), nullable=False
    )

Timestamp maps to TIMESTAMP WITH TIME ZONE on PostgreSQL, DATETIME on SQLite and DATETIME(fsp=6) on MySQL. Naive datetimes are rejected with a ValueError on bind; values are converted to UTC for SQLite and MySQL (which store no offset) and always come back as UTC-aware datetimes.

now() compiles to CURRENT_TIMESTAMP, CURRENT_TIMESTAMP(6) on MySQL and an equivalent strftime expression on SQLite, and is usable as server_default, onupdate and server_onupdate.

JSON

JSON maps to JSONB on PostgreSQL and to the dialect's JSON type elsewhere, with none_as_null=True. Values are serialized with orjson. Its comparator adds portable operators that compile per dialect:

from sqlargon.types import JSON


class Document(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    tags: Mapped[list] = mapped_column(JSON())
    meta: Mapped[dict] = mapped_column(JSON())


await repo.list(Document.tags.contains(["python", "sql"]))
await repo.list(Document.tags.has_any_key(["python", "rust"]))
await repo.list(Document.tags.has_all_keys(["python", "sql"]))
await repo.list(Document.meta.json_value("owner") == "john")
Operator PostgreSQL MySQL SQLite
contains(x) @> JSON_CONTAINS json_each self-join
has_any_key([...]) ?\| JSON_CONTAINS_PATH(..., 'one', ...) EXISTS over json_each
has_all_keys([...]) ?& JSON_CONTAINS_PATH(..., 'all', ...) json_each self-join
json_value(key) ->> JSON_EXTRACT JSON_EXTRACT

has_any_key / has_all_keys are portable over arrays, not objects

Use them to test membership in a JSON array — that is the one meaning all three dialects agree on. Against a JSON object they diverge: PostgreSQL and MySQL test the object's keys, while the SQLite fallback tests the values produced by json_each. To query a key of an object portably, use json_value(key) instead.

The underlying function elements — json_contains, json_has_any_key, json_has_all_keys and json_value — are importable from sqlargon.types.json for use outside a JSON column. has_any_key and has_all_keys require string keys and raise ValueError otherwise.

Pydantic-validated columns

sqlargon.types.pydantic stores validated objects in a JSON column and hydrates them back on load:

from pydantic import BaseModel

from sqlargon.types.pydantic import Pydantic, ValidatedType


class Address(BaseModel):
    city: str
    zip_code: str


class Customer(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    address: Mapped[Address] = mapped_column(Pydantic(Address))
    scores: Mapped[list[int]] = mapped_column(ValidatedType(list[int]))

Pydantic(model) accepts either a model instance or a dict on write and always returns a model instance on read. ValidatedType(type_) does the same for any type a pydantic.TypeAdapter can handle; pass validate=False to skip validation on write. Both accept sa_column_type= to store in something other than JSON (e.g. sa.Text).

Mixins

sqlargon.mixins bundles the types above into reusable column sets:

Mixin Columns Extras
UUIDModelMixin idGUID primary key, uuid4 client default, GenerateUUID() server default
UUIDV7ModelMixin idGUID primary key, uuid7 client default, GenerateUUIDV7() server default
CreatedUpdatedMixin created_at, updated_atTimestamp, now() server defaults, onupdate is_new hybrid property
SoftDeleteMixin tombstone — boolean, defaults to false not_deleted and is_deleted hybrid properties
from sqlargon.mixins import CreatedUpdatedMixin, SoftDeleteMixin, UUIDV7ModelMixin


class User(UUIDV7ModelMixin, CreatedUpdatedMixin, SoftDeleteMixin, Base):
    name: Mapped[str] = mapped_column(sa.Unicode(255))

Both timestamps come from the database rather than from Python: they have no client-side default, so a single evaluation of now() per statement fills them, and updated_at is moved on by onupdate / server_onupdate — rows updated by raw SQL keep an accurate timestamp too. Because the two columns share one server-side value, a freshly inserted row has created_at == updated_at exactly, including every row of a multi-row insert.

The hybrids work at instance level and as SQL expressions, so either can be used in a filter:

await repo.list(User.is_new)        # created_at = updated_at
await repo.list(User.not_deleted)   # NOT tombstone
await repo.list(User.is_deleted)    # tombstone IS true

user = await repo.get(id=user_id)
user.is_new, user.not_deleted, user.is_deleted

SoftDeleteMixin only declares the column; to have deletes raise the flag automatically, build the repository on SoftDeleteRepository and the model on SoftDeleteBase, which is that mixin already combined with Base:

from sqlargon import SoftDeleteBase


class User(UUIDV7ModelMixin, CreatedUpdatedMixin, SoftDeleteBase):
    name: Mapped[str] = mapped_column(sa.Unicode(255))

SoftDeleteModel is the matching type variable, bound to SoftDeleteBase.