跳转至

ComposedErManager —— 同进程多 engine 组合

ComposedErManager 把多个自洽的 ErManager(各自焊死自己的 engine/session) 组合成一个查询代理。组合体产出的 resolver 能在一次 resolve 里跨过不同数据库 的实体,全程在单进程内,无 HTTP 桥。

它是 federation 的同进程对偶

  • federation(specs/012,见 federation.zh.md):跨进程组合, 跨服务关系走 HTTP。
  • ComposedErManager(specs/019):在单一进程内组合,跨 engine 关系走进程内 DataLoader(用户提供的闭包)。
blog engine                            shop engine
User ── posts ── Post                   Order ── items ── OrderItem
  │                                       ▲
  └─ orders ──── 跨 engine 边 ────────────┘   (User.id → Order.user_id)

工作原理

  • 按 entity 委托 —— 每个实体只属于一个 member。组合体把 has_entity / get_relationships / get_loader_for_entity 路由到所属 member;成员实体集必须 互斥(重复注册在构造时报错)。
  • 跨 engine 关系在组合体层声明 —— 跨 engine 关系(User → orders → Order)声明在 ComposedErManager 上,不在任一 member 的实体上。成员保持自洽:单独使用时对这条边 无感。这条边的 loader 是用户提供的闭包,内部打开目标 engine 的 session。
  • resolve 透明 —— composed.create_resolver() 产出单一 resolver;resolve() 经 跨 engine DataLoader 扇出。调用方看到一棵扁平的树。

组合 + 声明跨 engine 边

from sqlmodel import select
from nexusx import ComposedErManager, ErManager, Relationship

blog_er = ErManager(session_factory=blog_sf, entities=[User, Post])
shop_er = ErManager(session_factory=shop_sf, entities=[Order, OrderItem])

async def orders_by_user(user_ids: list[int]) -> list[list[Order]]:
    async with shop_sf() as s:                       # 用目标 engine 的 session
        result = await s.exec(select(Order).where(Order.user_id.in_(user_ids)))
    by: dict[int, list[Order]] = {}
    for o in result.all():
        by.setdefault(o.user_id, []).append(o)
    return [by.get(uid, []) for uid in user_ids]

composed = ComposedErManager(
    members=[blog_er, shop_er],
    cross_relationships=[
        (User, Relationship(
            fk="id", target=list[Order], name="orders", loader=orders_by_user,
        )),
    ],
)

跨 engine 边在组合体层声明一次。UserOrder 互不引用。

跨 engine 查询

γ —— DTO 树(Resolver)

class OrderDTO(DefineSubset):
    __subset__ = (Order, ("id", "total"))

class UserDTO(DefineSubset):
    __subset__ = (User, ("id", "name"))
    orders: list[OrderDTO] = []

Resolver = composed.create_resolver()
resolved = await Resolver().resolve([UserDTO(id=1, name="Alice")])
# Alice.orders 透明地在 shop engine 上 resolve

β —— GraphQL handler(注入组合体,US3)

handler = GraphQLHandler(er_manager=composed, entities=[User, Post, Order, OrderItem])
# @query 入口各自取自己的 session;一次查询跨 blog → shop。

与 federation 对比

federation(012) ComposedErManager(019)
范围 跨进程 单进程内
跨边传输 HTTP(每个服务一条嵌套 gql) 进程内 DataLoader(闭包)
成员单位 一个 nexusx service(独立 app/进程) 一个 ErManager(独立 engine/session)
装配 启动时 mount(await handler.federate(...) 构造(ComposedErManager(members=...)
适用场景 服务独立部署 单服务、多数据库

Voyager 分组与配色(022)

多 engine 组合后,Voyager 的 ER 图和 UseCase 页会按 member 分 cluster:给 member 设 service_name,它的实体(以及注册进 dto_classes 的 DTO)就归入以 该名字为标签的独立分组;再设 color,分组会带上背景色填充和同色边框:

blog_er = ErManager(
    session_factory=blog_async_session,
    entities=[CmUser, CmPost],
    service_name="blog",      # cluster 标签(组合体内必须唯一,重名构造期报错)
    color="#E3F2FD",          # 可选,建议浅色 #RRGGBB
)
shop_er = ErManager(
    session_factory=shop_async_session,
    entities=[CmOrder, CmOrderItem],
    service_name="shop",
    color="#FFF3E0",
)
composed = ComposedErManager(members=[blog_er, shop_er], ...)

要点:

  • opt-in:不设 service_name 的 member 回落现状(按 Python module 分组);不设 color 的 member 只分组、不填色(白底)。颜色不设自动调色板,完全由你声明。
  • color 依赖 service_name 生效——只设颜色不设名字时颜色被忽略。
  • 归属优先级:federation 物化类型按远端 service 聚簇(dashed)> member 按 service_name 聚簇(rounded)> Python module。两级可叠加,互不串扰。
  • Route 节点(UseCaseService 方法)不参与 member 分组——service 层组织方式与 数据源归属是两个正交维度。
  • 单体 ErManager(非组合)完全不受影响,设了 color 也不产生任何变化。

已知限制:service_name 若与本地某个真实 Python module 名前缀相同(如 service_name=blog 而存在模块 blogging.models),该模块的 cluster 会被 member 颜色误命中——取名时避开既有 module 前缀即可。

设计原则

决策 原因
member 是自洽的 ErManager 各自独占一个 engine;可独立复用
跨边在组合体层声明 member 保持纯粹;跨边是组合的属性,不属于任一 member(DD-02)
mutating 操作留在 member federate / initialize / add_virtual_entities 在 member 上做,从不在组合体上(FR-013)—— 组合体只查询
LoaderRegistry Protocol 组合体满足与 ErManager 相同的查询契约,故 create_resolver / GraphQLHandler 注入无需特殊处理

可运行 demo

demo/composed_er_manager/ 跑 blog + shop 双 engine 示例:

uv run uvicorn demo.composed_er_manager.app:app --port 8030

打开 http://localhost:8030/graphql,跨 engine 查询:

{ CmUser { get_users { name posts { title } orders { total } } } }

posts 在 blog engine 内 resolve;orders 在同一查询里跳到 shop engine。

参见:Federation(跨进程对偶)、自定义关系