Skip to main content

ORM Models (Declarative)

SQLAlchemy 2.0's declarative style — class Foo(Base): ... with Mapped[...]/mapped_column(...) — works against milvusql the same way it does against any other dialect: Base.metadata.create_all() emits DDL through sqlglot-milvus, and Session/AsyncSession read and write rows through it.

from sqlalchemy import Boolean, Integer, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from milvusql_sqlalchemy.types import VECTOR


class Base(DeclarativeBase):
pass


class CharacteristicKind(Base):
__tablename__ = "characteristic_kind"

id: Mapped[str] = mapped_column(String(36), primary_key=True, autoincrement=False)
default_owner: Mapped[str | None] = mapped_column(String(36), nullable=True)
available_for_customer: Mapped[bool] = mapped_column(Boolean)
code: Mapped[str] = mapped_column(String(64))
name: Mapped[str] = mapped_column(String(128))
main_for_customer: Mapped[int] = mapped_column(Integer)
is_deleted: Mapped[bool] = mapped_column(Boolean)
parent_id: Mapped[str | None] = mapped_column(String(36))
embedding: Mapped[list[float]] = mapped_column(VECTOR(4))


engine = create_engine("milvusql:///items.db")
Base.metadata.create_all(engine)

with engine.begin() as conn:
from sqlalchemy import text
conn.execute(text("LOAD TABLE characteristic_kind")) # explicit, see below

with Session(engine) as session:
session.add(CharacteristicKind(
id="...", available_for_customer=True, code="c1", name="n1",
main_for_customer=1, is_deleted=False,
embedding=[0.1, 0.2, 0.3, 0.4],
))
session.commit()

with Session(engine) as session:
row = session.get(CharacteristicKind, "...")

This end-to-end shape (a model with a string primary key, nullable columns, plain scalar columns, and a VECTOR column, exercised through create_all()insertSession.get()) is verified directly against Milvus Lite, not assumed from SQLAlchemy's general dialect contract — two real gaps were found and fixed while confirming it (see below), not just this specific example.

Milvus-specific caveats for declarative models

__table_args__ = {"schema": "..."} does not do what it does on a relational database. Milvus has no schema/namespace concept — only a top-level db_name, which is already part of the connection URL. If you set a "schema" table arg, SQLAlchemy renders it into DDL text as CREATE TABLE schema.table (...), but sqlglot-milvus's CREATE TABLE handling only reads the right-hand part of a dotted identifier as the collection name — the schema segment is silently dropped, not honored, not rejected. Don't rely on it for isolation; use a separate db_name (a separate connection/engine) instead if you need namespacing.

No UUID column type. Milvus's own field types don't include anything UUID-shaped (_map_datatype in milvusql.translate.ast_to_pymilvus accepts only Milvus's actual column types — the integer/float/boolean/JSON scalars in _SCALAR_TYPES, plus VARCHAR, TEXT, ARRAY<T>(n), VECTOR(n), SPARSEVEC and the dimensioned vector spellings — and raises NotSupportedError for anything else, CHAR included). sqlalchemy.Uuid/ sqlalchemy.dialects.postgresql.UUID render as CHAR(...), which MilvusQL's grammar doesn't accept as a column type either. Store UUIDs as Mapped[str] / mapped_column(String(36)) and convert with Python's own uuid.UUID(...)/str(...) at the boundary — there's no dialect-level UUID type to import here the way there is for Postgres.

A collection is loaded automatically the first time it's queried (SELECT, Session.get(), a relationship lazy-load, ...) — milvusql's Cursor/AsyncCursor auto-LOAD a collection on first use per connection and cache the result (see Core → Overview); milvusql-sqlalchemy gets this for free since it drives the ORM through that same Cursor. Base.metadata.create_all() still doesn't create an index, and a vector search still needs one regardless of load state, so LOAD TABLE <name> (through conn.execute(text(...)), same as any other raw DDL/DML this dialect doesn't have a Table-level Core construct for) is only needed explicitly if you want control over replica count or want to warm a collection up ahead of traffic — not as a precondition for querying through the ORM.

Nullable columns need nullable=True (or an Optional/| None Mapped type) to actually be nullable at the Milvus schema level, not just at the Python type level — milvusql's CREATE TABLE translation reads the DDL's NOT NULL presence/absence and sets the Milvus FieldSchema's own nullable flag accordingly (confirmed directly: without it, inserting Python None for an unconstrained column used to fail with FieldData 'x' has 0 rows, expected 1). Primary-key and VECTOR/SPARSEVEC columns can never be nullable, regardless of what's declared — Milvus itself doesn't support it for those.

Async

Everything above works the same way through AsyncSession/create_async_engine("milvusql+aio:///...") — see Async Engine.