Native SQLAlchemy columns (escape hatch)

Alchemiq covers the most common column types out of the box. When you need something it does not model natively - JSONB, custom TypeDecorators, computed columns, exotic PK types - declare the column using standard SQLAlchemy 2.0 syntax and alchemiq will integrate it transparently.


Declaring a native column

Use Mapped[...] with mapped_column(...) exactly as you would in plain SQLAlchemy:

from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import JSONB

class Document(Model):
    id: PK[int]
    payload: Mapped[dict | None] = mapped_column(JSONB)

Alchemiq detects the Mapped[...] = mapped_column(...) signature and registers the column in __alchemiq_fields__ as a passthrough (_NativeField). SQLAlchemy owns the column definition; alchemiq does not rewrite the annotation or call its own build_column pipeline for it.


First-class integration

A native column registered in __alchemiq_fields__ participates in the full alchemiq query and serialization stack:

  • Filtering: repo.filter(payload__contains={"key": "value"}) - the field name resolves through __alchemiq_fields__ the same way typed fields do.

  • Ordering: repo.order_by("payload") works as expected.

  • Serialization: doc.to_dict() and doc.to_pydantic() include the native column in their output by default.

  • Schema exposure: Document.to_schema() returns a Pydantic class that exposes the native column as a field, with the Python type inferred from Mapped[dict | None].

  • Primary key discovery: a native column declared with primary_key=True is recognised as the model’s PK for repository operations and FK referencing.


No eager validation

Native columns carry no eager validation. The validate() hook that fires on every field assignment for typed alchemiq fields is not wired for native columns - that is the point of the escape hatch. Correctness of the value is your responsibility.


Config reconciliation

After SQLAlchemy maps the model (super().__init_subclass__() completes), alchemiq runs reconcile_native_fields to read the actual nullable, unique, index, and primary_key flags from the mapped Column on cls.__table__ and fills _NativeField.config from those authoritative values. This means __alchemiq_fields__ always reflects the real database column attributes, even though alchemiq did not build the column itself.


Native relationship() escape hatch

To declare a relationship that the alchemiq sugar does not support - a through-model M2M with extra columns, a self-referential M2M, or any fully custom join - use a native SQLAlchemy relationship():

from sqlalchemy.orm import Mapped, relationship

post_tag = ...  # a SQLAlchemy Table object for the through-model

class Post(Model):
    id: PK[int]
    tags: Mapped[list[Tag]] = relationship(secondary=post_tag, lazy="raise_on_sql")

When alchemiq’s pipeline processes Post, it detects the relationship() value via the NATIVE_RELATIONSHIP sentinel and skips it in prepare_fields - the column is not added to __alchemiq_fields__ and no eager validation is wired. SQLAlchemy maps the relationship normally.

Registration is reactive, driven by an unloaded-relationship access. When you read a relationship attribute that was not loaded, SQLAlchemy raises InvalidRequestError / DetachedInstanceError; the model’s __getattribute__ catches that error and lazily calls register_native_relationships, which runs configure_mappers() and reads the resolved direction and target from the SQLAlchemy inspector, entering the relationship into __alchemiq_relationships__. The call is idempotent - it runs at most once per class. If the relationship was eager-loaded (via select_related / prefetch_related), no error is raised and this path is never taken; alchemiq’s own sugar relationships are registered eagerly at class-definition time and are skipped here. Once registered, the relationship works with select_related and prefetch_related by name.

ClickHouse models are unaffected - register_native_relationships is a no-op for ClickHouseModel because it has no __alchemiq_relationships__ registry.

Note

Alchemiq respects whatever lazy= strategy you declare. Without lazy="raise_on_sql", accessing the relationship on an unloaded instance will trigger implicit SQL - the typed RelationNotLoaded exception is not raised.