py-store

Migrating from SQLAlchemy (or Beanie)

The problem

Your team already has SQLAlchemy / SQLModel models, and possibly Beanie over Motor for MongoDB, and now the same service has to run against MongoDB and a relational database — per tenant, per environment, or during a migration. Keeping a declarative model class and a Beanie Document in sync means two schemas, two query builders and two sets of behaviours to keep aligned.

Why py-store

Concept mapping

SQLAlchemy / SQLModel Beanie / Motor py-store
create_engine() / async engine AsyncIOMotorClient init(connections) — a {name: connection} map
sessionmaker() / Session AsyncIOMotorDatabase no session object; the schema is located by (source, namespace, collection) on each call
declarative model / SQLModel class Document subclass store.register({...}) — a runtime JSON dict
select(Order).where(...) Order.find(...) store.query(gql, params) — GQL tree
session.get(Order, id) / .one_or_none() Document.get(id) store.query_one(gql, params)
relationship() Link a relations entry (model, type, localField, foreignField)
.options(selectinload(Order.items)) fetch_links=True reference the relation name inside the GQL selection set
session.add(obj) + commit() doc.insert() store.insert / store.mutation / store.upsert
bulk_insert_mappings() insert_many() store.insert_many(schema, docs)
func.count() + group_by() aggregation pipeline GQL $group / $having; relation aggregate predicates
Alembic migration (no equivalent) read-only sync_schema introspection
session.begin() client.start_session() per-source driver transaction (single SQL source only)

Walkthrough

Before — SQLAlchemy 2.0, one class per table, one session per unit of work:

class OrderItem(Base):
    __tablename__ = "order_items"
    id: Mapped[str] = mapped_column(String, primary_key=True)
    order_id: Mapped[str] = mapped_column(ForeignKey("orders.id"))
    sku: Mapped[str]
    qty: Mapped[int]

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[str] = mapped_column(String, primary_key=True)
    status: Mapped[str]
    amount: Mapped[float]
    items: Mapped[list[OrderItem]] = relationship()

async with async_session() as session:
    orders = (await session.execute(
        select(Order).where(Order.status == "open").options(selectinload(Order.items))
    )).scalars().all()

After — py-store, one schema definition and one query, on either backend:

from pymongo import AsyncMongoClient
from py_store import init, store
from py_store import executors

store.register({
    "name": "OrderItem",
    "collection": "order_items",
    "fields": {
        "_id": "string",
        "orderId": {"type": "string", "default": ""},
        "sku": {"type": "string", "default": ""},
        "qty": {"type": "int", "default": 1},
    },
})
store.register({
    "name": "Order",
    "collection": "orders",
    "idPrefix": "OD",
    "fields": {
        "_id": "string",
        "status": {"type": "string", "default": "draft"},
        "amount": {"type": "float", "default": 0},
    },
    "relations": {
        "items": {"model": "OrderItem", "type": "many",
                  "localField": "_id", "foreignField": "orderId"},
    },
})

GQL = "Order($condition:@c0){ _id, status, amount, items { sku, qty } }"
PARAMS = {"c0": {"status": "open"}}

# MongoDB: native aggregation + $lookup
await init(AsyncMongoClient("mongodb://localhost:27017")["mydb"])
orders = await store.query(GQL, PARAMS)

# PostgreSQL: same schema, same GQL — parameterized SQL + JOIN
await init({"default": executors.create_connection("postgres", pg_pool)})
orders = await store.query(GQL, PARAMS)

When you point py-store at an existing physical database, sync_schema(backend, driver, introspect_options=None, overlay=None, datasource=None, namespace=None, register_defs=True) reads its structure into the registry (introspect → merge overlay → register). The overlay argument is where you pass your local schema dicts so that permissions and computed columns — which introspection cannot see — survive the merge.

What you lose

Being explicit about the gap saves a rewrite later:

See also