diff --git a/.specify/feature.json b/.specify/feature.json index 01e00b76..ba0468d7 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1,3 @@ -{"feature_directory":"specs/019-composable-er-manager"} +{ + "feature_directory": "specs/020-federation-config-orthogonal" +} diff --git a/demo/core_api/app.py b/demo/core_api/app.py index 62746b63..b6b0199d 100644 --- a/demo/core_api/app.py +++ b/demo/core_api/app.py @@ -159,25 +159,19 @@ async def get_sprints_with_tags(): @app.get("/api/sprints/top-tasks") -async def get_sprints_top_tasks(limit: int | None = None, order: str | None = None): - """Level 6: Paged — top-N tasks per sprint (caller overrides Paged default). +async def get_sprints_top_tasks(): + """Level 6: Paged — top-N tasks per sprint (Paged field default, fixed). - SprintTopTasks.tasks carries ``Paged(limit=2, order="NEWEST")`` as a - default; the page_loader slices per-parent (ROW_NUMBER). Query params - ``?limit=N&order=OLDEST`` flow through Resolver context to override the - field default per-request — Paged is the params home, context the override. + SprintTopTasks.tasks carries ``Paged(limit=2)`` (order omitted → Sprint + __pagination_orders__ default "NEWEST"); the page_loader slices per-parent + (ROW_NUMBER). Paged is declarative and fixed at the field — runtime input + belongs on a UseCase method signature, not Resolver context. """ stmt = build_dto_select(SprintTopTasks) async with async_session() as session: rows = (await session.exec(stmt)).all() dtos = [SprintTopTasks(**dict(row._mapping)) for row in rows] - context: dict = {} - if limit is not None: - context["limit"] = limit - if order is not None: - context["order"] = order - resolver = Resolver(context=context) if context else Resolver() - resolved = await resolver.resolve(dtos) + resolved = await Resolver().resolve(dtos) return [r.model_dump() for r in resolved] diff --git a/demo/core_api/dtos.py b/demo/core_api/dtos.py index 06d82d29..3efc69e2 100644 --- a/demo/core_api/dtos.py +++ b/demo/core_api/dtos.py @@ -184,13 +184,13 @@ def post_task_count(self): # ────────────────────────────────────────────────────────── class SprintTopTasks(DefineSubset): - """Sprint DTO with a Paged tasks field (top-N, caller can override). + """Sprint DTO with a Paged tasks field (top-N, fixed). - ``tasks`` carries ``Paged(limit=2)`` as a default (order omitted → uses + ``tasks`` carries ``Paged(limit=2)`` (order omitted → uses Sprint.__pagination_orders__ default "NEWEST"); the Resolver slices - per-parent via the page_loader (ROW_NUMBER). A caller passing - ``Resolver(context={"limit": N, "order": "OLDEST"})`` overrides the default - per-query — Paged is the home for params, context the override. + per-parent via the page_loader (ROW_NUMBER). Paged is declarative and + fixed at the field — runtime input belongs on a UseCase method signature, + not Resolver context. """ __subset__ = SubsetConfig(kls=Sprint, fields=['id', 'name']) diff --git a/demo/core_api/models/planning.py b/demo/core_api/models/planning.py index 7221d361..ed50c1d4 100644 --- a/demo/core_api/models/planning.py +++ b/demo/core_api/models/planning.py @@ -30,18 +30,8 @@ class Sprint(SQLModel, table=True): back_populates="sprint", sa_relationship_kwargs={"order_by": "Task.id"}, ) - # specs/016 Paged: order profiles for the tasks relationship. A DTO field - # `Annotated[list[TaskDTO], Paged(order="NEWEST")]` picks from these; the - # page_loader (built from order_by above) executes the slice. - __pagination_orders__ = { - "tasks": BatchPageConfig( - default_order="NEWEST", - orders={ - "NEWEST": PageOrder([OrderTerm("id", "desc")]), - "OLDEST": PageOrder([OrderTerm("id", "asc")]), - }, - ), - } + # specs/020: tasks order profile lives on Task (the sorted object); a DTO + # field `Annotated[list[TaskDTO], Paged(order="NEWEST")]` picks from it. @query async def get_sprints(cls, limit: int = 10) -> list["Sprint"]: diff --git a/demo/core_api/models/tasks.py b/demo/core_api/models/tasks.py index 1e168213..aa61938f 100644 --- a/demo/core_api/models/tasks.py +++ b/demo/core_api/models/tasks.py @@ -2,6 +2,7 @@ from sqlmodel import Field, Relationship, SQLModel, select +from nexusx import BatchPageConfig, OrderTerm, PageOrder from nexusx import Relationship as CustomRelationship from nexusx import mutation, query @@ -39,6 +40,15 @@ async def _tags_by_task_loader(task_ids: list[int]) -> list[list[Tag]]: class Task(SQLModel, table=True): __tablename__ = "core_api_task" + # specs/020: Task's own sort — read when Sprint.tasks is paginated. + __pagination_orders__ = BatchPageConfig( + default_order="NEWEST", + orders={ + "NEWEST": PageOrder([OrderTerm("id", "desc")]), + "OLDEST": PageOrder([OrderTerm("id", "asc")]), + }, + ) + id: int | None = Field(default=None, primary_key=True) title: str done: bool = False diff --git a/demo/federation/README.md b/demo/federation/README.md index 69f2edfb..f80dda22 100644 --- a/demo/federation/README.md +++ b/demo/federation/README.md @@ -94,8 +94,8 @@ await handler.er.initialize() | Transitive discovery | catalog reaches `users` via `reviews`' fragment | | β nested fetch | one gql per service returns the multi-level nested chain | | Multi-level members | `Review→Comment` and `User→UserConfig` resolved locally per service | -| `by__in` entry roots | `AutoQueryConfig(batch_keys=...)` on each member | -| Member-owned pagination order | `AutoQueryConfig(batch_pages=...)` on reviews | +| `by__in` entry roots | `__federation_keys__` on each member entity | +| Member-owned pagination order | `__pagination_orders__` on the reviews entity | | Voyager on the composed graph | `http://localhost:8022/voyager` (ER tab) — catalog only | ## UseCase composition over federated data (DefineSubset + Resolver) @@ -125,11 +125,9 @@ curl -X POST http://localhost:8022/api/catalog_service/composed_tree \ class ReviewDTO(DefineSubset): __subset__ = SubsetConfig( kls=reviews.Review, fields=("title", "rating", "product_id"), - federation_public=True, federation_join_key="product_id", - ) - __pagination_orders__ = BatchPageConfig( - default_order="HIGHEST_RATING", - orders={"HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")])}, + federation_public=True, + # join key + order derived from reviews.Review's + # __federation_keys__ / __pagination_orders__ (no DTO-level declaration) ) # catalog service: DTO references the member public DTO + Paged default (top-N) diff --git a/demo/federation/reviews_app.py b/demo/federation/reviews_app.py index 735c3cdb..1c40b785 100644 --- a/demo/federation/reviews_app.py +++ b/demo/federation/reviews_app.py @@ -38,6 +38,16 @@ class ReviewsBase(SQLModel): class Comment(ReviewsBase, table=True): __tablename__ = "fed_demo_comment" + # specs/020: Comment's own sort — read when Review.comments (or any owner's + # comments relationship) is locally paginated. Declared once on the sorted + # object and reused by every owner; no per-owner duplication. + __pagination_orders__ = BatchPageConfig( + default_order="NEWEST", + orders={ + "NEWEST": PageOrder([OrderTerm("id", "desc")]), + "OLDEST": PageOrder([OrderTerm("id", "asc")]), + }, + ) id: int | None = Field(default=None, primary_key=True) review_id: int = Field(foreign_key="fed_demo_review.id") author_id: int @@ -54,6 +64,7 @@ class Comment(ReviewsBase, table=True): class Review(ReviewsBase, table=True): __tablename__ = "fed_demo_review" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -64,18 +75,23 @@ class Review(ReviewsBase, table=True): # Review.comments becomes comments(limit, offset) on the member side. sa_relationship_kwargs={"order_by": "Comment.id"}, ) - # specs/015: local pagination order profiles — callers can now query - # comments(order: NEWEST|OLDEST, direction: ASC|DESC) - # order_by above stays as the fixed fallback when no profile/order is given. - __pagination_orders__ = { - "comments": BatchPageConfig( - default_order="NEWEST", - orders={ - "NEWEST": PageOrder([OrderTerm("id", "desc")]), - "OLDEST": PageOrder([OrderTerm("id", "asc")]), - }, - ), - } + # specs/020: Review's own sort — how its rows order when federated via + # page_by_product_id_in (and reused by ReviewDTO). Orthogonal to + # __federation_keys__, which only picks the entry field. Comment's own sort + # (for Review.comments) lives on Comment, not here. + __pagination_orders__ = BatchPageConfig( + default_order="HIGHEST_RATING", + orders={ + "HIGHEST_RATING": PageOrder( + [OrderTerm("rating", "desc")], + description="Highest rating first", + ), + "NEWEST": PageOrder( + [OrderTerm("created_at", "desc")], + description="Newest first", + ), + }, + ) class ReviewDTO(DefineSubset): @@ -90,11 +106,6 @@ class ReviewDTO(DefineSubset): kls=Review, fields=("title", "rating", "product_id"), federation_public=True, - federation_join_key="product_id", - ) - __pagination_orders__ = BatchPageConfig( - default_order="HIGHEST_RATING", - orders={"HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")])}, ) @@ -126,26 +137,7 @@ async def init_db() -> None: session_factory=async_session, # `by_product_id_in` is the batch root catalog drives (Product → Review). # Comment.author → users.User is driven against users' `by_id_in`. - auto_query_config=AutoQueryConfig( - batch_keys={"Review": ["product_id"]}, - batch_pages={ - "Review": { - "product_id": BatchPageConfig( - default_order="HIGHEST_RATING", - orders={ - "HIGHEST_RATING": PageOrder( - [OrderTerm("rating", "desc")], - description="Highest rating first", - ), - "NEWEST": PageOrder( - [OrderTerm("created_at", "desc")], - description="Newest first", - ), - }, - ) - } - }, - ), + auto_query_config=AutoQueryConfig(), service_name="reviews", dto_classes=[ReviewDTO], # reviews is itself mounted by catalog AND mounts users — opting in lets diff --git a/demo/federation/users_app.py b/demo/federation/users_app.py index 5a195f24..1a2a336e 100644 --- a/demo/federation/users_app.py +++ b/demo/federation/users_app.py @@ -30,6 +30,7 @@ class UserConfig(UsersBase, table=True): class User(UsersBase, table=True): __tablename__ = "fed_demo_user" + __federation_keys__ = ["id"] id: int | None = Field(default=None, primary_key=True) name: str email: str @@ -55,7 +56,7 @@ async def init_db() -> None: handler = GraphQLHandler( base=UsersBase, session_factory=async_session, - auto_query_config=AutoQueryConfig(batch_keys={"User": ["id"]}), + auto_query_config=AutoQueryConfig(), service_name="users", ) diff --git a/docs/advanced/federation.md b/docs/advanced/federation.md index 55ae11db..99703586 100644 --- a/docs/advanced/federation.md +++ b/docs/advanced/federation.md @@ -50,17 +50,24 @@ batch entry root for each join key the mounter will use: ```python from nexusx.federation.introspect import build_federable_app +# Declare federation join keys on the entity (specs/020) — the member's batch +# entry roots are generated from these, not from AutoQueryConfig. +class Review(Base, table=True): + __tablename__ = "review" + __federation_keys__ = ["product_id"] # → generates by_product_id_in(values) + handler = GraphQLHandler( base=Base, session_factory=session, - auto_query_config=AutoQueryConfig(batch_keys={"Review": ["product_id"]}), + auto_query_config=AutoQueryConfig(), # pure toggles now (default_limit etc.) service_name="reviews", ) app = build_federable_app(handler) # mounts POST /graphql + GET /nexusx/er-introspection ``` -`AutoQueryConfig(batch_keys=...)` generates `by_product_id_in(values: list)` -roots (`where field.in_(values)`) — the entry points the mounter's remote -loader drives. This is a generally-useful capability beyond federation. +`__federation_keys__` generates a `by__in(values: list)` root +(`WHERE key IN (values)`) for each declared field — the entry point the +mounter's remote loader drives. `AutoQueryConfig` now holds only toggles +(`default_limit`, `generate_by_id`, ...). ## Mount + query @@ -93,31 +100,28 @@ Open http://localhost:8022/ for GraphiQL on the catalog service and query ## Pagination -Pagination and physical sorting are owned by the member. The member explicitly -publishes named semantic order profiles through `batch_pages`; physical column -names and directions stay private to that service. +Pagination and physical sorting are owned by the member. An entity that declares +`__pagination_orders__` (a single sort profile) gets a paginated +`page_by__in` root for **every** federation key (in addition to the plain +`by__in`); physical column names and directions stay private to that +service. The sort is the entity's own property — orthogonal to which federation +key is the entry point. ```python from nexusx import BatchPageConfig, OrderTerm, PageOrder -AutoQueryConfig( - batch_keys={"Review": ["product_id"]}, - batch_pages={ - "Review": { - "product_id": BatchPageConfig( - default_order="NEWEST", - orders={ - "NEWEST": PageOrder( - [OrderTerm("created_at", "desc")] - ), - "HIGHEST_RATING": PageOrder( - [OrderTerm("rating", "desc")] - ), - }, - ) - } - }, -) +class Review(Base, table=True): + __tablename__ = "review" + __federation_keys__ = ["product_id"] # entry field(s) + __pagination_orders__ = BatchPageConfig( # entity's own sort (single) + default_order="NEWEST", + orders={ + "NEWEST": PageOrder([OrderTerm("created_at", "desc")]), + "HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")]), + }, + ) + # federation_keys picks the entry field; __pagination_orders__ picks the + # sort — orthogonal. One profile serves every federation key. ``` The caller chooses one of those profiles at query time, plus a direction @@ -179,11 +183,10 @@ class ReviewDTO(DefineSubset): __subset__ = SubsetConfig( kls=Review, fields=("title", "rating", "product_id"), federation_public=True, # expose via dto-introspection / dto-batch - federation_join_key="product_id", # auto-derivable when the subset has exactly one FK - ) - __pagination_orders__ = BatchPageConfig( - default_order="HIGHEST_RATING", - orders={"HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")])}, + # join key + order both come from the source entity now: + # join key ← Review.__federation_keys__ (single key → auto) + # order ← Review.__pagination_orders__ (the entity's single sort) + # multiple federation keys → select via federation_key="product_id". ) handler = GraphQLHandler(base=Base, ..., dto_classes=[ReviewDTO]) @@ -204,10 +207,10 @@ class ProductDTO(DefineSubset): ``` The `Paged(...)` default drives a SQL-level top-N on the member (via its -`__pagination_orders__` profile); a caller can override per-field through -`Resolver(context={...})`. Member values are read-only — a mounter adds fields -with its own `resolve_*` methods / `post_*` hooks, never by mutating member -values. +`__pagination_orders__` profile); it is fixed at the field — runtime input +belongs on a UseCase method signature, not Resolver context. Member values +are read-only — a mounter adds fields with its own `resolve_*` methods / +`post_*` hooks, never by mutating member values. ### β vs γ at a glance @@ -215,8 +218,27 @@ values. |---|---|---| | Composition unit | entity relationships (`RemoteRelationship`) | public DTO references (`DefineSubset` fields) | | Traversal | one nested gql per mounted service per level | Resolver `_batch_auto_load` via `dto-batch` | -| Pagination | gql args on the relationship field | `Paged(...)` field default + caller context | +| Pagination | gql args on the relationship field | `Paged(...)` field default (fixed) | | Entry | `GraphQLHandler` schema | `UseCaseService` + `create_resolver()` | + +## Migration from pre-020 (`batch_keys` / `batch_pages` / `federation_join_key`) + +Federation member config is now declared on the entity; `AutoQueryConfig` and +`SubsetConfig` no longer carry it. To migrate: + +| Old (removed in 020) | New | +|---|---| +| `AutoQueryConfig(batch_keys={"Review": ["product_id"]})` | `Review.__federation_keys__ = ["product_id"]` | +| `AutoQueryConfig(batch_pages={"Review": {"product_id": ...}})` | `Review.__pagination_orders__ = BatchPageConfig(...)` (entity's single sort) | +| `SubsetConfig(federation_join_key="product_id")` | derived from `Review.__federation_keys__` (auto for a single key; `federation_key=` selects among many) | +| DTO-level `__pagination_orders__` on `DefineSubset` | read from the source entity's single `__pagination_orders__` | + +A federation key always yields a `by__in` root; if the entity declares +`__pagination_orders__`, every federation key additionally yields +`page_by__in` (they coexist — a paginated relationship wires both the full +and paged loaders). Local-relationship pagination reads the **target** entity's +`__pagination_orders__` (e.g. `Comment`'s sort, when `Review.comments` is +paginated) — declared once on the sorted object, reused by every owner. | Member values | instances | DTOs (read-only; mounter computes its own) | See `demo/federation/` (reviews publishes `ReviewDTO`; catalog's `ProductDTO` diff --git a/docs/advanced/federation.zh.md b/docs/advanced/federation.zh.md index c67a1b47..84b46108 100644 --- a/docs/advanced/federation.zh.md +++ b/docs/advanced/federation.zh.md @@ -47,17 +47,23 @@ root: ```python from nexusx.federation.introspect import build_federable_app +# 在 entity 上声明联邦 join key(specs/020)——member 的批量入口根从这里生成, +# 不再来自 AutoQueryConfig。 +class Review(Base, table=True): + __tablename__ = "review" + __federation_keys__ = ["product_id"] # → 生成 by_product_id_in(values) + handler = GraphQLHandler( base=Base, session_factory=session, - auto_query_config=AutoQueryConfig(batch_keys={"Review": ["product_id"]}), + auto_query_config=AutoQueryConfig(), # 现在只持开关(default_limit 等) service_name="reviews", ) app = build_federable_app(handler) # 挂载 POST /graphql + GET /nexusx/er-introspection ``` -`AutoQueryConfig(batch_keys=...)` 生成 `by_product_id_in(values: list)` root -(`where field.in_(values)`)——挂载方远程 loader 驱动的入口。这是单体 nexusx 也 -受益的通用能力,非联邦专属。 +`__federation_keys__` 为每个声明字段生成 `by__in(values: list)` root +(`WHERE key IN (values)`)——挂载方远程 loader 驱动的入口。`AutoQueryConfig` +现在只持开关(`default_limit`、`generate_by_id` 等)。 ## 挂载 + 查询 @@ -90,30 +96,26 @@ http://localhost:8022/(catalog 服务的 GraphiQL),查 ## 分页 -分页和物理排序归数据所在的 member。member 通过 `batch_pages` 显式发布命名的 -语义 order profile,实际列名和方向不跨服务暴露。 +分页和物理排序归数据所在的 member。声明了 `__pagination_orders__`(单一排序 +profile)的 entity,其**每个** federation key 都会额外生成 `page_by__in` +分页根(与 `by__in` 共存);实际列名和方向不跨服务暴露。排序是 entity 自己 +的属性 —— 与用哪个 federation key 作入口正交。 ```python from nexusx import BatchPageConfig, OrderTerm, PageOrder -AutoQueryConfig( - batch_keys={"Review": ["product_id"]}, - batch_pages={ - "Review": { - "product_id": BatchPageConfig( - default_order="NEWEST", - orders={ - "NEWEST": PageOrder( - [OrderTerm("created_at", "desc")] - ), - "HIGHEST_RATING": PageOrder( - [OrderTerm("rating", "desc")] - ), - }, - ) - } - }, -) +class Review(Base, table=True): + __tablename__ = "review" + __federation_keys__ = ["product_id"] # 入口字段 + __pagination_orders__ = BatchPageConfig( # entity 自己的排序(单一) + default_order="NEWEST", + orders={ + "NEWEST": PageOrder([OrderTerm("created_at", "desc")]), + "HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")]), + }, + ) + # federation_keys 决定入口字段;__pagination_orders__ 决定排序 —— 正交。 + # 一个 profile 服务所有 federation key。 ``` 查询者在查询期挑选其中一个 profile,并指定方向(`ASC`/`DESC`)。挂载方把 @@ -166,11 +168,10 @@ class ReviewDTO(DefineSubset): __subset__ = SubsetConfig( kls=Review, fields=("title", "rating", "product_id"), federation_public=True, # 通过 dto-introspection / dto-batch 暴露 - federation_join_key="product_id", # subset 恰好含一个 FK 时可省略(自动派生) - ) - __pagination_orders__ = BatchPageConfig( - default_order="HIGHEST_RATING", - orders={"HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")])}, + # join key + order profile 现在都从源 entity 读: + # join key ← Review.__federation_keys__(单 key 自动) + # order ← Review.__pagination_orders__ (entity 单一排序) + # 多 federation key 时用 federation_key="product_id" 选择。 ) handler = GraphQLHandler(base=Base, ..., dto_classes=[ReviewDTO]) @@ -191,8 +192,7 @@ class ProductDTO(DefineSubset): ``` `Paged(...)` 默认值驱动 member 侧 SQL 层 top-N(经其 `__pagination_orders__` -profile);调用方可通过 `Resolver(context={...})` 逐字段覆盖。member 值是只读的—— -mounter 用自己 DTO 上的 `resolve_*` / `post_*` 加字段,不修改 member 值。 +profile);它在字段上固化——运行时入参应在 UseCase 方法签名上,不在 Resolver context。member 值是只读的——mounter 用自己 DTO 上的 `resolve_*` / `post_*` 加字段,不修改 member 值。 ### β vs γ 一览 @@ -200,9 +200,26 @@ mounter 用自己 DTO 上的 `resolve_*` / `post_*` 加字段,不修改 member |---|---|---| | 组合单元 | 实体关系(`RemoteRelationship`) | public DTO 引用(`DefineSubset` 字段) | | 遍历方式 | 每层每挂载服务一次嵌套 gql | Resolver `_batch_auto_load` 走 `dto-batch` | -| 分页 | 关系字段上的 gql 参数 | `Paged(...)` 字段默认 + caller context | +| 分页 | 关系字段上的 gql 参数 | `Paged(...)` 字段默认(固化) | | 入口 | `GraphQLHandler` schema | `UseCaseService` + `create_resolver()` | | member 值 | 实例 | DTO(只读;mounter 自己计算) | +## 从 pre-020 迁移(`batch_keys` / `batch_pages` / `federation_join_key`) + +联邦 member 配置现在声明在 entity 上;`AutoQueryConfig` 和 `SubsetConfig` 不再承载它。迁移: + +| 旧(020 移除) | 新 | +|---|---| +| `AutoQueryConfig(batch_keys={"Review": ["product_id"]})` | `Review.__federation_keys__ = ["product_id"]` | +| `AutoQueryConfig(batch_pages={"Review": {"product_id": ...}})` | `Review.__pagination_orders__ = BatchPageConfig(...)` (entity 单一排序) | +| `SubsetConfig(federation_join_key="product_id")` | 从 `Review.__federation_keys__` 推导(单 key 自动;多 key 用 `federation_key=` 选) | +| `DefineSubset` 上的 DTO 级 `__pagination_orders__` | 从源 entity 的单一 `__pagination_orders__` 读 | + +一个 federation key 总会生成 `by__in` 根;若 entity 声明了 +`__pagination_orders__`,每个 federation key 都额外生成 `page_by__in` +(两者共存——分页联邦关系同时 wire full 和 paged 两个 loader)。本地关系分页 +读 **target** entity 的 `__pagination_orders__`(如 `Review.comments` 分页时读 +`Comment` 的排序)——在被排序对象上声明一次,每个 owner 复用。 + 可运行示例见 `demo/federation/`(reviews 发布 `ReviewDTO`;catalog 的 `ProductDTO` 引用它)。 diff --git a/docs/changelog.md b/docs/changelog.md index 87e40d3e..67386a3c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,23 @@ description: "Release-by-release changelog for nexusx, following semver — majo > Pre-3.0 history is not included here. See `git log` and the historical tags for changes before 3.0.0. +## Unreleased + +- breaking: + - **Federation member config orthogonalized to the entity (specs/020)**: Removed + `AutoQueryConfig.batch_keys` / `batch_pages` and `SubsetConfig.federation_join_key`. + Member federation capability is now declared on the entity: + - `__federation_keys__` — which fields are federation batch entry keys; each generates a + `by__in` root (`WHERE key IN (values)`). + - `__pagination_orders__` — the single order-profile carrier: a key in + `__federation_keys__` additionally yields a `page_by__in` root, while a local + relation name yields a local paginated loader (one carrier, routed by + `__federation_keys__`). + - γ DTO (`federation_public=True`) join key + order are derived from the source entity's + `__federation_keys__` / `__pagination_orders__`; `SubsetConfig.federation_join_key` → + `federation_key` (a selector for multi-key entities, auto for a single key). + `AutoQueryConfig` now holds only toggles (`default_limit`, `generate_by_id`, ...). + ## 5.4 ### 5.4.1 (2026-8-8) diff --git a/specs/020-federation-config-orthogonal/checklists/requirements.md b/specs/020-federation-config-orthogonal/checklists/requirements.md new file mode 100644 index 00000000..04b239d0 --- /dev/null +++ b/specs/020-federation-config-orthogonal/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Federation 配置正交化 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-09 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) — 注:API 命名(`__federation_keys__` 等)作为 API 设计契约的必要部分保留,无具体代码文件/行号 +- [x] Focused on user value and business needs — 开发者用户价值(配置集中、去重、声明/执行分离) +- [x] Written for non-technical stakeholders — 面向 nexusx 开发者(federation 使用者) +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded — member 侧去重 + order 统一;mounter 侧 join_remote 不在范围(见 Assumptions) +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- 本 spec 是 API/架构重构,「用户」是 nexusx 开发者;必要的 API 命名(`__federation_keys__`、`__pagination_orders__`、BatchPageConfig)作为设计契约保留,不算 implementation detail。 +- mounter 侧 `join_remote` 不在去重范围(见 spec Assumptions),因 mounter 与 member 是不同服务,join_remote 是跨服务契约。 +- 参考 note 15(federation 准备清单 + 正交性分析 + 本设计的讨论过程)。 diff --git a/specs/020-federation-config-orthogonal/contracts/federation-entity-declaration.md b/specs/020-federation-config-orthogonal/contracts/federation-entity-declaration.md new file mode 100644 index 00000000..8fdd8468 --- /dev/null +++ b/specs/020-federation-config-orthogonal/contracts/federation-entity-declaration.md @@ -0,0 +1,61 @@ +# Contract: Entity 联邦声明(开发者面向) + +开发者通过 entity 上的两个 dunder 声明 federation member 的联邦能力。这是新模型的**唯一**声明入口(member 侧)。 + +## 声明形态 + +```python +class Comment(BaseEntity, table=True): + # Comment 自己的排序 —— 被 Review.comments(或任何 owner)分页时读它 + __pagination_orders__ = BatchPageConfig(default_order="NEWEST", + orders={"NEWEST": PageOrder([...])}) + ... + + +class Review(BaseEntity, table=True): + product_id: int + rating: int + comments: list[Comment] = Relationship(...) + + # ① 联邦外键标记 —— 哪些字段是联邦批量入口(纯标记,不带 order) + __federation_keys__ = ["product_id"] + + # ② Review 自己的排序 —— 被联邦批量分页(page_by_product_id_in)时读它 + __pagination_orders__ = BatchPageConfig(default_order="HIGHEST_RATING", + orders={"HIGHEST_RATING": PageOrder([...])}) + # 注:comments 的排序在 Comment 上,不在 Review —— 排序归被排序对象 +``` + +## 规则 + +1. `__federation_keys__` 的字段**必须是 entity 的实际字段**(生成 `by__in` 要 `WHERE key IN (values)`)。 +2. `__pagination_orders__` 是 entity 的**单一** BatchPageConfig(该 entity 自己的排序),不按维度分。 +3. 联邦批量分页读 **owner 自己**的 `__pagination_orders__`;本地关系分页读 **target** 的(如 Review.comments 读 Comment 的)。两者读不同 entity。 +4. 联邦字段的根生成(FR-002 + FR-003 **叠加**): + - **每个** `__federation_keys__` 字段都生成 `by__in`(批量根,`WHERE key IN`) + - entity 声明了 `__pagination_orders__` → 每个 federation key **额外**生成 `page_by__in`(共用这一个 profile) + - 分页联邦关系同时 wire 两个根(mounter full loader 用 by_、paged loader 用 page_by_) + +## γ DTO(federation_public) + +```python +class ReviewDTO(DefineSubset): + __subset__ = SubsetConfig(kls=Review, fields=("title", "rating", "product_id"), + federation_public=True) + # join key 自动从 Review.__federation_keys__ 推导(单 key 时) + # 多 key 时:federation_key="product_id" ← 选择器,引用 entity 已声明的 key 名 +``` + +## 退场项(breaking,直接删) + +| 旧用法 | 新模型 | +|---|---| +| `AutoQueryConfig(batch_keys={"Review":["product_id"]})` | 删 → entity `__federation_keys__` | +| `AutoQueryConfig(batch_pages={"Review":{"product_id": BatchPageConfig(...)}})` | 删 → entity `__pagination_orders__ = BatchPageConfig(...)`(单一) | +| `SubsetConfig(federation_join_key="product_id")` | 退化为 `federation_key`(选择器,单 key 时省略) | +| `DTO.__pagination_orders__`(γ 单独的) | 统一到源 entity `__pagination_orders__` | + +## 不变项 + +- `RemoteService` / `RemoteRef` / `RemoteRelationship`(mounter 侧声明模型)—— 保持不变(正交性分析已确认这部分设计良好)。 +- mounter 侧 `RemoteRelationship(join_remote=...)` —— 保留(跨服务契约,不在此去重范围,见 spec Clarifications Q1)。 diff --git a/specs/020-federation-config-orthogonal/data-model.md b/specs/020-federation-config-orthogonal/data-model.md new file mode 100644 index 00000000..91de25f2 --- /dev/null +++ b/specs/020-federation-config-orthogonal/data-model.md @@ -0,0 +1,51 @@ +# Data Model: Federation 配置正交化 + +核心数据 = entity 上的两个 dunder + 复用的 BatchPageConfig + 退化的 AutoQueryConfig / SubsetConfig。 + +## `__federation_keys__`(新增,entity 级) + +- **形态**:`__federation_keys__: list[str]` —— 字段名列表,如 `["product_id"]` +- **语义**:标记这些字段是联邦批量入口(**纯标记,不携带 order**) +- **消费方**: + 1. GraphQLHandler / ErManager 初始化扫描收集 + 2. AutoQueryConfig 读它生成 `by__in` / `page_by__in` 根 + +## `__pagination_orders__`(已存在,语义扩展) + +- **形态**:`__pagination_orders__: BatchPageConfig`(**单一**,该 entity 自己的行怎么排序) +- **语义(扩展)**:排序是被排序对象的单一属性,与 federation key(分桶维度)/关系归属正交。 + - 联邦批量分页(owner 自己被外部拉取)→ 读 owner 自己的 `__pagination_orders__` + - 本地关系分页(如 Review.comments)→ 读 **target** entity 的 `__pagination_orders__`(Comment 的排序),不在 owner 配 +- **不再有路由规则**:联邦读 owner、本地读 target,两者读不同 entity,天然不冲突。 + +## `BatchPageConfig`(已存在,不变) + +- **形态**:`default_order: str` + `orders: dict[str, PageOrder]`(PageOrder = list[OrderTerm]) +- **语义**:一个维度的 order profile(默认排序 + 可选排序集) +- **复用**:本地关系维度 + 联邦批量维度共用同一格式(正交化的关键 —— order 格式统一) + +## `AutoQueryConfig`(已存在,职责退化) + +- **删除**:`batch_keys`、`batch_pages` +- **保留**:`default_limit`、`generate_by_id`、`generate_by_filter`、`enabled` +- **新职责**:读 entity 的 `__federation_keys__` + `__pagination_orders__`,调 `_create_by_keys_in_query` / `_create_page_by_keys_in_query` 生成根(声明/执行分离) + +## `SubsetConfig`(DTO 层,退化) + +- `federation_join_key` → 退化为 `federation_key`(**选择器**:源 entity 多 federation key 时选哪个;默认 None = 自动单 key) +- `federation_public` 保留(标记 DTO 为联邦公开) + +## 关系图 + +``` +entity (Review) entity (Comment) +├── __federation_keys__ = ["product_id"] ← 入口 └── __pagination_orders__ = BatchPageConfig(...) ← Comment 自己的排序 +└── __pagination_orders__ = BatchPageConfig(...) ← Review 自己的排序 + +两个轴正交,各读其主: +- 联邦批量分页:读 owner 自己(Review.__pagination_orders__) + → entity 有 __pagination_orders__ → 每个 federation key 出 page_by__in + → 无 → 只 by__in +- 本地关系分页:读 target(Comment.__pagination_orders__),不在 owner 配 + → Comment 被 Review/Post/... 多 owner 挂载,排序只声明一次 +``` diff --git a/specs/020-federation-config-orthogonal/plan.md b/specs/020-federation-config-orthogonal/plan.md new file mode 100644 index 00000000..40f6377a --- /dev/null +++ b/specs/020-federation-config-orthogonal/plan.md @@ -0,0 +1,63 @@ +# Implementation Plan: Federation 配置正交化 + +**Branch**: `020-federation-config-orthogonal` | **Date**: 2026-08-09 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/020-federation-config-orthogonal/spec.md` + +## Summary + +把 federation member 侧配置正交化:**联邦外键降为纯标记**(entity `__federation_keys__`)、**order 单一化归被排序对象**(`__pagination_orders__` 单一,联邦读 owner/本地读 target)、**γ join key 归并到 entity**、**AutoQueryConfig 退化为执行者**(读 entity 生成 by_/page_by 根)。解决正交性分析 5 个问题。breaking(直接删旧配置,federation 用户少)。 + +## Technical Context + +- **Language/Version**: Python 3.10+(requires-python >=3.10) +- **Primary Dependencies**: SQLModel, pydantic 2, graphql-core, fastapi, aiodataloader, fastmcp +- **Storage**: N/A(配置模型重构,不涉及持久化) +- **Testing**: pytest + pytest-asyncio + pytest-cov(基线 1517 passed,当前在 fix/paged-core-api-no-context-override 分支) +- **Project Type**: library(nexusx) +- **Performance Goals**: federation fetch 行为等价(新声明模型 vs 旧 batch_keys/batch_pages),零回归 +- **Constraints**: breaking 改动(直接删 `batch_keys`/`batch_pages` + `federation_join_key`,无 deprecated 期);聚焦 member 侧(mounter `join_remote` 不动) +- **Scale/Scope**: member 侧联邦配置模型重构 —— standard_queries / loader.registry / subset / federation(remote_loader,introspect,manager) / entity dunder 扫描 / demo / docs / tests + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +项目 `.specify/memory/constitution.md` 为空模板(未定义 principles),无强制 gate。遵循项目既有纪律: + +- **公共 API breaking 须显式标注**(memory: feedback_public_api_not_breaking)—— 本 feature 是 breaking,changelog 须标注;满足。 +- **entity 只承载 resource、计算放 DTO**(memory: project_entity_resource_no_computation)—— `__federation_keys__` 是声明标记(resource 元信息),不是计算逻辑;符合。 +- **spec-kit 产物用中文**(CLAUDE.md)—— 满足。 + +无违反。Phase 1 后复查。 + +## Project Structure + +单 library 项目,复用既有 `src/nexusx` 结构。 + +```text +src/nexusx/ +├── standard_queries.py # AutoQueryConfig: 删 batch_keys/batch_pages,改读 entity 生成 by_/page_by 根 +├── subset.py # SubsetConfig: federation_join_key 退化为「选择器」(多 key 时选哪个),不再声明 key 值 +├── loader/ +│ └── registry.py # 读 entity __federation_keys__ + __pagination_orders__(本地关系读 target,FR-005) +├── federation/ +│ ├── introspect.py # γ DTO 内省: join key 从源 entity __federation_keys__ 推导(不再 DTO federation_join_key) +│ ├── remote_loader.py # β/γ fetch 读统一声明 +│ └── manager.py # federate 调度(声明底座统一,内部调度可仍分 β/γ) +└── (entity dunder 扫描) # __federation_keys__ 识别(GraphQLHandler/ErManager 初始化收集,复用 __pagination_orders__ 扫描模式) + +demo/federation/ +├── reviews_app.py # 迁移: __federation_keys__ + __pagination_orders__(删 batch_keys/batch_pages) +├── catalog_app.py # mounter 基本不变;ReviewDTO 删 federation_join_key +└── users_app.py # 迁移(若有 batch 配置) + +docs/advanced/federation.md(+.zh) # 新声明模型文档 +tests/ # federation 相关测试迁移 + 新声明模型测试 +``` + +**Structure Decision**: 单 library,无新目录。核心改动在 standard_queries / subset / loader.registry / federation/,加 entity dunder 扫描(复用 `__pagination_orders__` 已有的扫描机制,registry.py:145)。 + +## Complexity Tracking + +无 Constitution 违反,无需填。 diff --git a/specs/020-federation-config-orthogonal/quickstart.md b/specs/020-federation-config-orthogonal/quickstart.md new file mode 100644 index 00000000..93d12902 --- /dev/null +++ b/specs/020-federation-config-orthogonal/quickstart.md @@ -0,0 +1,51 @@ +# Quickstart 验证: Federation 配置正交化 + +验证新声明模型(entity `__federation_keys__` + `__pagination_orders__`)端到端跑通,行为与旧 `batch_keys`/`batch_pages` 等价。 + +## 前置 + +三层联邦 demo(`demo/federation/{catalog,reviews,users}_app.py`)已迁移到新声明模型: + +- `reviews_app.py`:`Review` 用 `__federation_keys__ = ["product_id"]` + `__pagination_orders__`;删 `AutoQueryConfig(batch_keys=..., batch_pages=...)`。 +- `catalog_app.py`:`ReviewDTO` 删 `federation_join_key`(自动推导)。 +- `users_app.py`:同上(若有 batch 配置)。 + +## 验证场景(端到端) + +1. 起 member + mounter(按序): + ```bash + uv run uvicorn demo.federation.users_app:app --port 8020 & + uv run uvicorn demo.federation.reviews_app:app --port 8021 & + uv run uvicorn demo.federation.catalog_app:app --port 8022 & + ``` +2. catalog GraphiQL(http://localhost:8022/graphql)跑联邦查询: + ```graphql + { Product { by_filter { + reviews(limit: 5) { items { title rating } + pagination { has_more total_count } } + } } } + ``` +3. **期望**:reviews 按 `HIGHEST_RATING` 排序返回 top-5(order 来自 `Review.__pagination_orders__` 单一),分页 metadata 正确。行为与旧 `batch_pages` 配置**等价**。 + +## 回归锚点(SC-004) + +- 三层联邦 demo 全部跑通(catalog→reviews→users 透明 fetch) +- member 用新声明(无 `batch_keys`/`batch_pages`)下,mounter 联邦行为零回归 + +## 自动化测试 + +```bash +# federation 分页 / DTO 联邦相关 +uv run pytest tests/test_dto_paged_remote.py tests/test_dto_paged.py tests/test_paged_provider.py +# by__in / page_by__in 生成 + γ DTO join key 推导(新增测试) +uv run pytest tests/ -k "federation or paged or batch_root" +# 全量回归 +uv run pytest -q +``` + +## 验证完成的标志 + +- [ ] demo 三层联邦在新声明模型下跑通,查询结果与旧模型一致 +- [ ] `by__in` / `page_by__in` 从 entity `__federation_keys__` + `__pagination_orders__` 正确生成 +- [ ] γ DTO join key 从源 entity 自动推导(单 key),`federation_join_key` 已删 +- [ ] 全量测试零回归(基线 1517 passed) diff --git a/specs/020-federation-config-orthogonal/research.md b/specs/020-federation-config-orthogonal/research.md new file mode 100644 index 00000000..da7de2ba --- /dev/null +++ b/specs/020-federation-config-orthogonal/research.md @@ -0,0 +1,47 @@ +# Research: Federation 配置正交化 + +spec 已经过 clarify(无 NEEDS CLARIFICATION)。本文档记录**实现层**的技术决策(spec 定了 WHAT,这里定 HOW 的关键选择)。 + +--- + +## 决策 1:`__federation_keys__` 怎么被框架识别/收集 + +- **选择**:entity 上的类级 dunder(`__federation_keys__ = ["product_id"]`),GraphQLHandler / ErManager 初始化时扫描 base 的所有 entity 子类,收集带 `__federation_keys__` 的 entity + 其字段。 +- **理由**:和 `__pagination_orders__` 同模式(已有的 entity 级 dunder,`registry.py:145` 已在读它)。复用现有扫描机制,零新概念。 +- **替代(否决)**:字段注解 `Annotated[int, FederationKey(...)]` —— 更细粒度但侵入每个字段定义,且扫描注解成本高、与 SQLModel Field 定义交错。 + +## 决策 2:`__pagination_orders__` 单一 + 本地关系归 target(FR-004/005) + +- **选择**:`__pagination_orders__` 是 entity 的**单一** BatchPageConfig(该 entity 自己的行怎么排序),不再按维度分 dict。排序归**被排序对象**: + - 联邦批量分页(owner 被外部拉取)→ 读 owner 自己的 `__pagination_orders__` + - 本地关系分页(Review.comments)→ 读 **target** 的 `__pagination_orders__`(Comment 的),不在 owner 配 +- **理由**:排序与分桶维度(federation key)/关系归属**正交**。旧设计把 order 绑在 federation key 维度(dict[key])+ 本地关系放 owner,导致:① 同一 entity 多 federation key 重复配同一排序;② Comment 被 N 个 owner 挂载时每个 owner 重复配 Comment 排序。单一 + 归 target 消除两者。 +- **替代(否决)**:`dict[federation_key, cfg]` + 靠 federation_keys 路由本地/联邦 —— 把"按哪个字段进"和"怎么排"耦合,且本地关系归 owner 致重复。 + +## 决策 3:AutoQueryConfig 退化后的根生成 + +- **选择**:AutoQueryConfig 不再持有 `batch_keys` / `batch_pages`。根生成函数(`_create_by_keys_in_query` / `_create_page_by_keys_in_query`)改为接收**从 entity 扫描来的 federation keys + 对应 order profile**(order profile 从该 entity 的 `__pagination_orders__` 取)。 +- **理由**:声明(entity)/ 生成(AutoQueryConfig 的 `_create_*` 函数)分离。AutoQueryConfig 退化为只持有 `default_limit` / `generate_by_id` / `generate_by_filter` 开关 + 触发根生成的执行者,回归本职。 +- **调用点**:`add_standard_queries` / handler 初始化时,遍历 entity 的 `__federation_keys__`,对每个 key 调 `_create_*` 生成根。 + +## 决策 4:γ DTO join key 从源 entity 推导 + +- **选择**:DTO(DefineSubset)`federation_public=True` 时,join key 从其**源 entity**(`__subset__.kls`)的 `__federation_keys__` 推导。 + - 源 entity **单** federation key → DTO 自动用之,无需声明。 + - 源 entity **多** federation key → DTO 须显式选哪个(`SubsetConfig(federation_key="product_id")` 作**选择器**,引用 entity 已声明的 key 名,而非自己声明 key 值)。 +- **理由**:join key 单一来源(entity)。`SubsetConfig.federation_join_key` 退化为「选择器」(多 key 时指名),默认(单 key)自动推导,不再承载 key 的语义声明。 +- **开放(plan 阶段不阻塞)**:多 federation key 的 DTO 选择机制具体签名 —— 倾向 `federation_key: str | None`(选 entity 的哪个 key),默认 None=自动单 key。 + +## 决策 5:`by__in` vs `page_by__in` 由 entity 级 `__pagination_orders__` 决定 + +- **选择**:entity 声明了 `__pagination_orders__` → 其**每个** federation key 都额外生成 `page_by__in`(共用这一个 profile);未声明 → 只 `by__in`。能否分页是 entity 的能力,不是某个字段的属性。 +- **理由**:page_/by_ 从 per-key profile 有无 → entity 级一刀切,更直白。多 federation key 共用一个排序 profile(正交:federation key 只管入口,排序只管怎么排)。 +- **替代(否决)**:per-key dict profile —— 同一 entity 多 federation key 要重复配同一排序。 + +--- + +## 风险与回滚 + +- **风险**:federation 还嫩 + 这是个跨多模块的 breaking 重构(standard_queries / subset / loader.registry / federation/),改动面大。 +- **缓解**:三层联邦 demo(catalog→reviews→users)作为端到端回归锚点(SC-004);先迁 demo 验证声明模型跑通,再迁测试/文档。 +- **回滚**:单分支 `020-federation-config-orthogonal`(或基于当前 fix/paged 分支再开),不合 master 前可整体回滚。 diff --git a/specs/020-federation-config-orthogonal/spec.md b/specs/020-federation-config-orthogonal/spec.md new file mode 100644 index 00000000..880bf04d --- /dev/null +++ b/specs/020-federation-config-orthogonal/spec.md @@ -0,0 +1,119 @@ +# Feature Specification: Federation 配置正交化 + +**Feature Branch**: `020-federation-config-orthogonal` + +**Created**: 2026-08-09 + +**Status**: Draft + +**Input**: User description: "Federation 配置正交化:把 member 侧联邦配置从 AutoQueryConfig 移到 entity,统一 order profile。" + +## 背景与问题 + +nexusx federation 的 member 侧配置当前散落在多处(GraphQLHandler 参数、AutoQueryConfig 的 `batch_keys`/`batch_pages`、entity 的 `__pagination_orders__`、DTO 的 `SubsetConfig` federation_*),同一信息(join key、order profile)被 β(entity 关系联邦)和 γ(DTO 联邦)两条路径各声明一份,导致重复、易漂移、概念边界模糊(详见 note 15 federation 正交性分析)。 + +本 feature 把 member 侧联邦配置正交化:**联邦外键降为纯标记、order 单一化归被排序对象(联邦读 owner/本地读 target)、配置集中到 entity、AutoQueryConfig 退化为执行者**。 + +## Clarifications + +### Session 2026-08-09 + +- Q: mounter 侧 `join_remote` 是否纳入本 feature 的去重范围? → A: **不纳入**(Option A)。mounter 与 member 是独立服务,`join_remote` 是 mounter 对 member 的跨服务契约(mounter 不该、也无法知道 member 的 `__federation_keys__` 内部声明;且 member 可能有多个外键,mounter 必须显式指明按哪个 join)。本 feature 只消除 **member 侧**的重复(`batch_keys` / `batch_pages` / `federation_join_key` → entity `__federation_keys__`)。 +- Q: 旧配置(`batch_keys` / `batch_pages` / `federation_join_key`)的移除策略? → A: **直接删**(Option A),无 deprecated 期、不保留兼容读法 / DeprecationWarning。federation 用户少,提供迁移文档(demo 为准),changelog 标 breaking。 + +## User Scenarios & Testing + +> 「用户」= 使用 nexusx federation 的开发者。 + +### User Story 1 — 在 entity 上集中声明联邦能力(Priority: P1) + +开发者定义一个 federation member(如 reviews 服务)时,在 entity 上一处声明它的全部联邦能力:哪些字段是联邦批量入口(`__federation_keys__`)、entity 自己的行怎么排序(`__pagination_orders__`,单一 profile)。不再到 AutoQueryConfig 里写 `batch_keys`/`batch_pages`,也不再在 DTO 上单独写 `federation_join_key` / `DTO.__pagination_orders__`。 + +**Why this priority**: 这是正交化的核心 —— 声明点从 4 处收敛到 entity 1 处,消除 join key / order profile 的重复声明。其余故事都建立在这个集中声明之上。 + +**Independent Test**: 给定一个 member entity,它通过 `__federation_keys__` + `__pagination_orders__` 声明联邦能力后,框架能据此生成 `by__in` 批量根、mounter 能成功联邦它,全程不读 `AutoQueryConfig.batch_keys/batch_pages`。 + +**Acceptance Scenarios**: + +1. **Given** 一个 Review entity 带有 `__federation_keys__=["product_id"]` 和 `__pagination_orders__=BatchPageConfig(...)`(entity 自己的单一排序), **When** 框架初始化该 member, **Then** 生成 `Review.by_product_id_in` 批量根 + `page_by_product_id_in` 分页根(桶内按该 profile 排序)。 +2. **Given** 同样的 entity 声明, **When** mounter 通过 RemoteRelationship 联邦它, **Then** mounter 能批量 fetch 并按声明排序,行为与旧 `batch_keys`/`batch_pages` 配置等价。 +3. **Given** member 用新声明(无 batch_keys/batch_pages), **When** 跑三层联邦 demo(catalog→reviews→users), **Then** 全部通过。 + +--- + +### User Story 2 — order 归被排序对象,联邦与本地各读其主(Priority: P2) + +排序是被排序对象的单一属性,与「按哪个字段分桶(federation key)」「谁拥有关系」正交。`__pagination_orders__` 是 entity 的**单一** BatchPageConfig(不再按维度分): +- **联邦批量分页**(被外部 member 拉取,如 Review 经 product_id 被 catalog 拉取):读 Review 自己的 `__pagination_orders__`(Review 的行怎么排)。 +- **本地关系分页**(如 Review.comments):读 **target** entity 的 `__pagination_orders__`(Comment 的行怎么排),不在 owner Review 上配 —— Comment 被 N 个 owner 挂载时排序只声明一次。 + +**Why this priority**: 排序归位到被排序对象,消除「本地分页配置在 owner 重复」「order 绑死在 federation key 维度」两个反模式。是正交化的第二根支柱。 + +**Independent Test**: Review 同时有本地关系分页(comments)和联邦批量分页(product_id);Review 的联邦排序在 Review 自己,Comment 的本地排序在 Comment 自己,互不耦合。 + +**Acceptance Scenarios**: + +1. **Given** Review 声明 `__pagination_orders__`(单一,如 rating desc)+ `__federation_keys__=["product_id"]`, Comment 声明自己的 `__pagination_orders__`(如 id desc), **When** 联邦批量 `page_by_product_id_in` + 本地 `Review.comments` 分页, **Then** 前者按 Review 的 profile、后者按 Comment 的 profile,各自正确。 +2. **Given** Comment 同时被 Review.comments 和 Post.comments 挂载, **When** 两者分页, **Then** 都用 Comment 自己的 `__pagination_orders__`,不在 Review/Post 上重复声明。 + +--- + +### User Story 3 — γ DTO join key 归并到 entity(Priority: P3) + +开发者做 γ(DTO 联邦)时,DTO 的 `federation_join_key` 不再单独在 SubsetConfig 里声明,而是复用源 entity 的 `__federation_keys__`。join key 单一来源(entity)。 + +**Why this priority**: 消除 join key 在 β(batch_keys)/γ(federation_join_key)的重复,统一到 entity。依赖 US1 的 `__federation_keys__`。 + +**Independent Test**: 一个 γ DTO(federation_public=True)的 join key 由其源 entity 的 `__federation_keys__` 决定,DTO 上不再写 federation_join_key。 + +**Acceptance Scenarios**: + +1. **Given** ReviewDTO 的源 entity Review 声明 `__federation_keys__=["product_id"]`, **When** ReviewDTO 标记 federation_public=True, **Then** γ 联邦用 product_id 作为 join key,无需 DTO 上再声明 federation_join_key。 +2. **Given** 旧的 `SubsetConfig(federation_join_key="product_id")` 用法, **When** 迁移到新模型, **Then** join key 只在 entity 上一处。 + +--- + +### Edge Cases + +- **本地关系 vs 联邦**:本地关系分页读 target 的 `__pagination_orders__`,联邦读 owner 自己的 —— 两者读不同 entity,天然不冲突,无需重名判断。 +- **旧配置处理**:旧的 `AutoQueryConfig(batch_keys=..., batch_pages=...)` 与 DTO `federation_join_key` 如何处理?federation 用户少,直接移除(breaking),提供迁移文档(以 demo 为准)。 +- **纯本地 entity**:一个 entity 没有联邦能力(无 `__federation_keys__`)?它仍可声明 `__pagination_orders__`(单一),作为本地关系 target 被分页时用。 +- **联邦 entity 不分页**:member entity 不需要分页(只 `by__in`,不 `page_by`)?不声明 `__pagination_orders__` → 所有 federation key 只生成 `by__in`。声明了 → 每个 federation key 都额外生成 `page_by__in`(共用这一个 profile)。 + +## Requirements + +### Functional Requirements + +- **FR-001**: 系统 MUST 支持 entity 上的 `__federation_keys__` 声明,标记哪些字段是联邦批量入口(纯标记,不携带排序配置)。 +- **FR-002**: 系统 MUST 据 `__federation_keys__` 为每个标记字段生成 `by__in` 批量查询根(`WHERE key IN (values)`)。 +- **FR-003**: 若 entity 声明了 `__pagination_orders__`(单一 BatchPageConfig),系统 MUST 为其**每个** federation key 额外生成 `page_by__in` 分页批量根(共用该 profile);未声明则只 `by__in`。 +- **FR-004**: `__pagination_orders__` MUST 是 entity 的**单一** BatchPageConfig(该 entity 自己的行怎么排序),不再按维度(federation key / 关系名)分。排序与分桶维度(federation key)正交。 +- **FR-005**: 本地关系分页 MUST 读 **target** entity 的 `__pagination_orders__`(被排序对象的排序);联邦批量分页读 owner 自己的。两者读不同 entity,不靠 federation_keys 路由同一 dict。 +- **FR-006**: AutoQueryConfig MUST 移除 `batch_keys` / `batch_pages` 两个声明字段;改为读 entity 的 `__federation_keys__` + `__pagination_orders__` 生成对应根(声明在 entity,生成在 AutoQueryConfig)。**直接移除,不保留兼容读法 / DeprecationWarning**(breaking,见 Clarifications Q2)。 +- **FR-007**: γ DTO 的 `federation_join_key` MUST 归并:join key 由源 entity 的 `__federation_keys__` 决定,DTO SubsetConfig 不再单独声明 join key。 +- **FR-008**: 声明(entity)/ 执行(AutoQueryConfig 生成根)MUST 分离 —— entity 只声明能力,AutoQueryConfig 只读 entity 生成查询根,不承载联邦声明。 +- **FR-009**: 迁移 MUST 覆盖 demo(reviews/catalog/users_app)、文档(docs/advanced/federation 双语)、测试(federation 相关),确保新声明模型一致。 + +### Key Entities + +- **`__federation_keys__`**(entity 级,新增):联邦外键标记 —— 声明哪些字段是联邦批量入口(如 `["product_id"]`)。纯标记,不携带排序。框架据此生成 `by__in` 根。 +- **`__pagination_orders__`**(entity 级,已存在、语义扩展):entity 自己的**单一**排序 profile(BatchPageConfig)—— 该 entity 的行怎么排序。联邦批量分页读 owner 自己的这一个值;本地关系分页读 target 的这一个值。 +- **BatchPageConfig**(已存在):order profile 载体 —— `default_order` + `orders{名字: PageOrder}`。本地关系和联邦批量共用同一格式。 +- **AutoQueryConfig**(已存在、职责退化):从「联邦声明载体」退化为「读 entity 生成 by_/page_by 根的执行者」。移除 batch_keys/batch_pages。 + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: federation member **侧**的 join key(如 product_id)声明点从 3 处(`batch_keys` / `batch_pages` / `federation_join_key`)降到 entity 上 1 处(`__federation_keys__`)。mounter 侧 `join_remote` 保留(跨服务契约,不在此去重范围)。 +- **SC-002**: 同一个 order profile(如 HIGHEST_RATING)只声明一份(旧:β batch_pages 一份 + γ DTO.`__pagination_orders__` 一份;新:`__pagination_orders__` 一份)。 +- **SC-003**: federation member 的联邦能力配置全部集中在 entity(GraphQLHandler 参数 / AutoQueryConfig / DTO 不再承载联邦 join key 或 order 声明)。 +- **SC-004**: 三层联邦 demo(catalog→reviews→users)在新声明模型下行为与旧模型等价(功能零回归)。 +- **SC-005**: AutoQueryConfig 的职责回归「自动查询生成开关」(default_limit / generate_by_id / generate_by_filter),不再含联邦声明字段。 + +## Assumptions + +- federation 当前用户少,breaking 改动可接受:直接移除 `AutoQueryConfig.batch_keys/batch_pages` 和 DTO `federation_join_key`,不保留长 deprecated 期(提供迁移文档,以 demo 为准)。 +- β(entity 关系联邦)和 γ(DTO 联邦)共享新的 entity 声明底座(`__federation_keys__` + `__pagination_orders__`);两者内部调度路径(remote_loader / dto_remote_loader)可仍分开,但用户侧声明统一。 +- **mounter 侧 `join_remote` 不在本 feature 的「去重」范围**:mounter 与 member 是不同服务,`RemoteRelationship(join_remote=...)` 是 mounter 对 member 的契约(mounter 不知道 member entity 的 `__federation_keys__`,必须显式告诉框架按 member 的哪个字段 join)。本 feature 聚焦 member 侧去重 + order 统一。 +- 现有 federation 公共 API(RemoteService / RemoteRef / RemoteRelationship)的声明模型保持不变(正交性分析已确认这部分设计良好)。 diff --git a/specs/020-federation-config-orthogonal/tasks.md b/specs/020-federation-config-orthogonal/tasks.md new file mode 100644 index 00000000..58005148 --- /dev/null +++ b/specs/020-federation-config-orthogonal/tasks.md @@ -0,0 +1,155 @@ +# Tasks: Federation 配置正交化 + +## 实现进度(2026-08-09,第四轮 — 全部完成,待发版) + +**分支**:`020-federation-config-orthogonal` + +**已完成(US1 收尾 + US2 + US3)**: +- T001 ✅ 分支 +- T003 ✅ 真删 `AutoQueryConfig.batch_keys/batch_pages`(参数 + 赋值);`manager.py` 错误消息 + `registry.py` docstring 同步去 batch_* +- T004/T005 ✅ `add_standard_queries` 读 entity dunder 生成根 —— **关键修正**:「叠加」非互斥(FR-002 每个 federation key 都生成 `by__in` + FR-003 有 profile 额外 `page_by__in`),适配 federation manager `full_br`(分页关系双根) +- T006 ✅ `registry._resolve_local_page_capability` 加 federation key 路由判断 +- US1 测试/demo 迁移 ✅ 18 federation 测试 + page_config + reviews/users demo +- T007-T008 ✅ **US2**:γ DTO 不再自带 `__pagination_orders__`,`add_dto_batch_roots` 改读源 entity `__pagination_orders__[join_key]`;元类去掉 DTO 级保留;contracts「有→page_,无→by_」修正为叠加 +- T009-T011 ✅ **US3**:γ DTO join key 从源 entity `__federation_keys__` 推导(单 key 自动 / 多 key 用 `SubsetConfig.federation_key` 选择器);`federation_join_key` → `federation_key`;stamp `__federation_join_key__` 保留为推导结果缓存(introspect/standard_queries 读它不变) +- US2/US3 测试/demo 迁移 ✅ 6 dto 测试 + reviews_app ReviewDTO(去 federation_join_key + `__pagination_orders__`) +- 全量回归 ✅ **1517 passed, 6 skipped**(零回归,两轮均 1517) + +**Polish ✅**:SC-004 demo 端到端验证通过(β deep chain catalog→reviews→users errors None + γ composed_tree);docs(federation.md/.zh.md 代码示例改新声明 + 迁移说明表 + demo README);changelog breaking 标注(Unreleased 段) + +**剩余**:发版(release skill;breaking → major;changelog Unreleased → 定版本号 + tag) + +--- + +**Input**: Design documents from `/specs/020-federation-config-orthogonal/`(spec.md / plan.md / research.md / data-model.md / contracts/) + +**Tests**: 含迁移 + 新声明模型测试(本 feature 是重构,测试跟随迁移)。 + +**Organization**: 按 user story 组织(US1 P1 / US2 P2 / US3 P3),US1 为 MVP,US2/US3 依赖 US1。 + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: 可并行(不同文件、无依赖) +- **[Story]**: 所属 user story +- 每个任务含具体文件路径 + +--- + +## Phase 1: Setup + +**Purpose**: feature 分支(复用既有 src/nexusx,无新基础设施) + +- [x] T001 开 feature 分支 `020-federation-config-orthogonal`(基于 master,或基于当前 fix/paged-core-api-no-context-override 分支叠加以包含 P6 修复) + +--- + +## Phase 2: Foundational(阻塞所有 US) + +**Purpose**: `__federation_keys__` 的扫描识别底座 —— US1/US2/US3 都依赖 + +**⚠️ CRITICAL**: 本阶段完成前不得开始任何 US + +- [ ] T002 在 entity 初始化扫描里识别 `__federation_keys__`:GraphQLHandler / ErManager 初始化时收集各 entity 的 `__federation_keys__`(复用 `__pagination_orders__` 已有的扫描机制,`registry.py:145`),存入 registry 供下游生成/路由读 —— `src/nexusx/loader/registry.py` + `src/nexusx/handler.py` + +**Checkpoint**: 框架能从 entity 收集到 `__federation_keys__`,US 实现可开始 + +--- + +## Phase 3: User Story 1 — entity 集中声明 + AutoQueryConfig 退化(Priority: P1)🎯 MVP + +**Goal**: member 在 entity 上一处声明联邦能力(`__federation_keys__` + `__pagination_orders__`),AutoQueryConfig 退化为读 entity 生成 by_/page_by 根(删 batch_keys/batch_pages)。 + +**Independent Test**: 给定 Review entity 带 `__federation_keys__=["product_id"]` + `__pagination_orders__["product_id"]`,框架生成 `Review.by_product_id_in` / `page_by_product_id_in` 根,mounter 能联邦它,全程不读 AutoQueryConfig.batch_keys/batch_pages。 + +- [ ] T003 [US1] AutoQueryConfig 删除 `batch_keys` / `batch_pages` 字段(`__init__` 签名 + docstring + 赋值)—— `src/nexusx/standard_queries.py` +- [ ] T004 [US1] 根生成函数 `_create_by_keys_in_query` / `_create_page_by_keys_in_query` 改为接收「从 entity 扫描来的 federation key + 对应 order profile(取自该 entity 的 `__pagination_orders__`)」而非 AutoQueryConfig 的 batch_* —— `src/nexusx/standard_queries.py` +- [ ] T005 [US1] `add_standard_queries` / handler 初始化遍历 entity 的 `__federation_keys__`,对每个 key 按「有无 order profile」调 `_create_by_*` 生成 `by__in`(无 profile)/ `page_by__in`(有 profile)根 —— `src/nexusx/standard_queries.py` + `src/nexusx/handler.py` +- [x] T006 [P] [US1] ~~`__pagination_orders__` 统一路由~~(**已演进为单一化**:本地关系读 target entity 的 `__pagination_orders__`,联邦读 owner 自己,不靠 federation_keys 路由同一 dict —— 见决策 2) + +**Checkpoint**: US1 完成 —— entity 声明 → 生成根 → 路由,端到端可用(demo 单 member 可验证) + +--- + +## Phase 4: User Story 2 — order 单一化归被排序对象(Priority: P2) + +**Goal**: 本地关系分页 + 联邦批量分页共用同一 `__pagination_orders__` 载体,框架靠 `__federation_keys__` 自动路由,移除 γ DTO 单独的 `__pagination_orders__` 路径。 + +**Independent Test**: Review 同时有本地关系分页(comments)和联邦批量分页(product_id),两者 order profile 都在同一个 entity `__pagination_orders__`,框架分别正确路由。 + +- [ ] T007 [US2] 移除 γ DTO 单独的 `__pagination_orders__` 读取路径(`introspect.py:317` / `standard_queries.py:1019`),统一到源 entity 的 `__pagination_orders__` —— `src/nexusx/federation/introspect.py` + `src/nexusx/standard_queries.py` +- [ ] T008 [US2] 验证 by vs page 根的区分完全由 `__pagination_orders__` 有无 order profile 决定(替代旧 batch_keys→by / batch_pages→page 二分),补测试覆盖 —— `src/nexusx/standard_queries.py` + `tests/` + +**Checkpoint**: US2 完成 —— order 单一载体、不区分对内对外,γ DTO 不再单独声明 order + +--- + +## Phase 5: User Story 3 — γ DTO join key 归并到 entity(Priority: P3) + +**Goal**: γ DTO 的 join key 从源 entity 的 `__federation_keys__` 推导,`SubsetConfig.federation_join_key` 退化为选择器(多 key 时选;单 key 自动)。 + +**Independent Test**: ReviewDTO(federation_public=True)的 join key 由源 Review 的 `__federation_keys__` 决定,DTO 上不再声明 join key 值。 + +- [ ] T009 [US3] γ DTO `federation_public=True` 时,join key 从源 entity(`__subset__.kls`)的 `__federation_keys__` 推导:单 key 自动取;多 key 用 `SubsetConfig.federation_key` 选择 —— `src/nexusx/subset.py` + `src/nexusx/federation/introspect.py` +- [ ] T010 [US3] `SubsetConfig.federation_join_key` 重命名/退化为 `federation_key`(选择器,引用 entity 已声明的 key 名,默认 None=自动单 key)—— `src/nexusx/subset.py` +- [ ] T011 [US3] γ 内省(`introspect.py`)读 join key 从源 entity `__federation_keys__`(不再读 DTO `__federation_join_key__`)—— `src/nexusx/federation/introspect.py` + +**Checkpoint**: US3 完成 —— join key 单一来源(entity),DTO 不再声明 join key 值 + +--- + +## Phase 6: Polish & Cross-Cutting + +**Purpose**: demo 迁移 + 文档 + 测试 + 回归 + breaking 标注 + +- [ ] T012 [P] 迁移 `demo/federation/reviews_app.py`:Review 加 `__federation_keys__` + `__pagination_orders__`;删 `AutoQueryConfig(batch_keys/batch_pages)` + ReviewDTO `federation_join_key` —— `demo/federation/reviews_app.py` +- [ ] T013 [P] 迁移 `demo/federation/catalog_app.py`:ReviewDTO 删 `federation_join_key`(自动推导)—— `demo/federation/catalog_app.py` +- [ ] T014 [P] 迁移 `demo/federation/users_app.py`:若有 batch 配置,同 reviews 迁移 —— `demo/federation/users_app.py` +- [ ] T015 文档更新:`docs/advanced/federation.md` + `.zh.md` 改为新声明模型(entity 两 dunder)+ 迁移说明(旧 batch_keys/batch_pages/federation_join_key → 新)—— `docs/advanced/federation.md` + `docs/advanced/federation.zh.md` +- [ ] T016 测试迁移 + 新增:federation 相关测试改用新声明;新增「entity __federation_keys__ 生成 by_/page_ 根」「γ join key 自动推导」测试 —— `tests/` +- [ ] T017 全量回归 + breaking 标注:三层联邦 demo(catalog→reviews→users)跑通(SC-004);`uv run pytest` 零回归(基线 1517 passed);changelog 标 breaking(移除 batch_keys/batch_pages/federation_join_key)—— `docs/changelog.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: 无依赖,立即开始 +- **Foundational (Phase 2, T002)**: 依赖 Setup;**阻塞所有 US** +- **US1 (Phase 3)**: 依赖 T002;是 MVP +- **US2 (Phase 4)**: 依赖 US1(路由底座);与 US3 可并行(order vs join key,不同维度) +- **US3 (Phase 5)**: 依赖 US1(`__federation_keys__`);与 US2 可并行 +- **Polish (Phase 6)**: 依赖 US1-US3 完成 + +### Within US +- T002(扫描)→ T003-T006(US1 生成+路由)→ US2/US3 → Polish + +### Parallel Opportunities +- T006(registry 路由)可与 T003-T005(standard_queries 生成)并行(不同文件) +- T012/T013/T014(三个 demo)互不相关,可并行 +- US2(T007-T008)与 US3(T009-T011)可并行(order vs join key) + +--- + +## Implementation Strategy + +### MVP First(US1 only) +1. T001 开分支 → T002 扫描底座 → T003-T006 US1(entity 声明 → 生成根 → 路由) +2. **STOP 验证**:单 member(reviews)用新声明能生成 by_/page_ 根、被 mounter 联邦 +3. 通过后继续 US2/US3/Polish + +### Incremental Delivery +- US1(MVP:声明 + 生成 + 路由)→ US2(order 统一)→ US3(γ join key 归并)→ Polish(demo/文档/测试/回归) +- 每个 US 完成后可独立验证 + +### 回归锚点 +- 三层联邦 demo(SC-004)+ pytest 基线 1517 + +--- + +## Notes + +- breaking:直接删 batch_keys/batch_pages/federation_join_key,无 deprecated 期(spec Clarifications Q2) +- mounter 侧 join_remote 不动(spec Clarifications Q1) +- RemoteService/RemoteRef/RemoteRelationship 声明模型不变 +- 参考 research.md(5 个实现决策)、contracts/federation-entity-declaration.md(开发者声明契约) diff --git a/specs/021-federation-pagination-auto/spec.md b/specs/021-federation-pagination-auto/spec.md new file mode 100644 index 00000000..636f65c8 --- /dev/null +++ b/specs/021-federation-pagination-auto/spec.md @@ -0,0 +1,95 @@ +# Spec 议题:联邦分页自动化(去掉 RemoteRelationship.pagination,回归全局分页) + +> 状态:议题存档(待 spec-kit 流程细化)。020 merge 后启动。 + +## 一句话 + +去掉 `RemoteRelationship(pagination=True/False)` 这个 per-edge 参数;mounter 自动按 member 已声明的分页能力(`__pagination_orders__` → `page_by__in`)选择走分页根还是批量根。分页在 ER diagram 级别回归「全局开启/关闭」语义,不再逐边声明。 + +## 动机 + +### 1. `pagination` 参数是 020 后的冗余残留 + +020 后,member 是否暴露 `page_by__in` 已由 member entity 的 `__pagination_orders__` 决定(entity 级声明): + +``` +member 有 __pagination_orders__ → 每个 federation key 生成 by_X_in + page_by_X_in +member 无 → 只生成 by_X_in +``` + +即 **member 已经声明了"我能不能被分页拉取"**。mounter 再用 `RemoteRelationship(pagination=True)` 声明"我要分页拉你",是同一信息的重复声明 —— 两边必须对齐,漏一边就崩。 + +### 2. 实证:漏一条边就崩(不传递) + +对比实验(`tests/test_federation_pagination_transitive.py`,A→B→C 三层): + +| B→C 边的 `pagination` | A 查 `cs(limit:1) { items }` | 结果 | +|---|---|---| +| `pagination=True` | 走 `page_by_b_id_in`,返 `Result{items:[C1]}` | ✅ 正常 | +| 不写(默认 `False`) | 走 `by_b_id_in`,返普通 list `[C1,C2]`,客户端期望 `items` → 结构不匹配 | ❌ `ValidationError: cs.items Field required` | + +A 配了 A→B 的 `pagination=True`,**不会**让 B→C 自动开。B→C 要 B 自己声明,漏了就崩。这就是用户提的「应该带传递性质」的真实缺口。 + +### 3. 设计愿景(用户) + +> 「在 ER diagram 级别,分页应该就是一个全局开启或者关闭的逻辑。」 + +分页是 member ER diagram(schema)级别的属性,应由 member 自己的全局开关控制,不该是 mounter 逐边的声明。这与 020 精神一致:**声明在 member,消费方读它**(join key、order 都是这样,pagination 应跟进)。 + +## 设计方向 + +### 去掉 `RemoteRelationship.pagination` + +mounter 改为**自动选择**(`_check_target` 内部): + +```python +br = _find_batch_root(frag, join_remote, pagination=True) # 先找 page_by__in +if br is None: + br = _find_batch_root(frag, join_remote, pagination=False) # 回退 by__in +``` + +- member 有 `__pagination_orders__`(暴露 `page_by_`)→ mounter 自动走分页根 → 关系渲染 `Result{items, pagination}` +- member 无(只 `by_`)→ 自动回退批量根 → 关系渲染普通 list +- **mounter 不再声明 pagination**;中间层(B→C)无需任何配置,A 的查询穿透到 C 自然分页(自动消费 member 能力 = 自动传递) + +### 分页控制回归 member 侧(ER diagram 级全局) + +去掉 per-edge 后,分页的全部控制权在 member: + +| 控制点 | 作用 | 层级 | +|---|---|---| +| `__pagination_orders__`(entity) | member 暴不暴露 `page_by_`(联邦分页能力)+ 排序 profile | entity 级 | +| `enable_pagination`(handler) | member 自己本地关系(comments)分页的总开关 | handler 级(全局) | + +mounter 侧零分页配置 —— 只读 member 的能力。 + +## 影响分析 + +### breaking + +- `RemoteRelationship(pagination=...)` 参数移除。federation 用户少,直接删(无 deprecated 期,与 020 一致)。 + +### federation manager 核心改动 + +- `_validate_and_wire_remote_relationship`(manager.py:333):`_check_target` 的 `pagination=` 参数来源从 `rrel.pagination` 改为「探测 page_by_ 是否存在」。 +- 双 loader 逻辑(full_br `by_` + page_br `page_by_`):改为「有 page_by_ → wire paged(+ full);无 → 只 plain」。 +- `is_active_paginated_relationship`:`REMOTE_PAGED` 的判定从「rrel.pagination」改为「member 暴露了 page_by_」。 + +### schema 形态统一 + +- to-many 联邦关系:只要 member 支持分页(有 `__pagination_orders__`),mounter schema 一律 `Result{items, pagination}`。不再有「普通 list」选项(当前 `pagination=False` 的形态消失)。 +- 客户端查询统一:`rel(limit, order) { items { ... } pagination { has_more } }`。 +- to-one 不受影响(从不分页,照旧 by_id_in)。 + +## 开放问题(待 spec-kit clarify) + +1. **`enable_pagination` 的角色是否扩展**:当前只管本地关系分页。用户愿景是「ER diagram 级全局开关」—— 是否让 `enable_pagination` 也控制联邦 `page_by_` 的生成(即 member 关掉它就既不分页本地、也不暴露联邦 page_by_)?还是保持两者独立(`__pagination_orders__` 管联邦、`enable_pagination` 管本地)? + - 倾向:保持独立(两者正交,020 已确立)。但「全局开关」的语义可能需要一个新的统一概念。 +2. **mounter 能否强制不分页**:去掉后,member 支持分页时 mounter 总是走 page_by_。是否有场景 mounter 想要全量 list(不要 Result)?`page_by_(limit=None)` 可返全部,但结构仍是 Result。若确有需求,保留一个 opt-out(如 `RemoteRelationship(no_pagination=True)`,反向语义,默认 False)。 +3. **introspection/SDL 渲染**:`page_by_` 存在性 → 关系类型(Result vs list),需同步 sdl_generator / introspection。 + +## 关联 + +- specs/020(federation 配置正交化 —— 本议题是其分页维度的收尾) +- note 15(federation 准备清单)、note 16(分页场景全览) +- 实证测试:`tests/test_federation_pagination_transitive.py`(A→B→C 穿透分页;`pagination=True` 通过 / `False` 崩溃的对比) diff --git a/src/nexusx/execution/query_executor.py b/src/nexusx/execution/query_executor.py index 59793f82..6e7bf048 100644 --- a/src/nexusx/execution/query_executor.py +++ b/src/nexusx/execution/query_executor.py @@ -10,8 +10,8 @@ from pydantic import TypeAdapter from sqlmodel import SQLModel -from nexusx.loader.pagination import KeyedPaginatedPackage, PaginatedPackage from nexusx.execution.argument_builder import ArgumentBuilder +from nexusx.loader.pagination import KeyedPaginatedPackage, PaginatedPackage from nexusx.loader.registry import RelationshipKind from nexusx.query_parser import FieldSelection from nexusx.response_builder import get_relationship_names, serialize_with_model diff --git a/src/nexusx/federation/contract.py b/src/nexusx/federation/contract.py index fb3dfd0f..21331f83 100644 --- a/src/nexusx/federation/contract.py +++ b/src/nexusx/federation/contract.py @@ -115,7 +115,7 @@ class DTOFragment(BaseModel): # EntityFragment.scalar_fields. DTOs have no ORM column/relationship split, # so every model_fields entry is a scalar from the federation standpoint. scalar_fields: list[FieldDescriptor] = Field(default_factory=list) - join_key: str # federation join key (SubsetConfig.federation_join_key) + join_key: str # federation join key (derived from entity __federation_keys__) batch_root: BatchRoot # generated DTO batch root (by__in DTO variant) # Cross-service out-edges on the DTO (__relationships__). remote_refs: list[RelDescriptor] = Field(default_factory=list) diff --git a/src/nexusx/federation/introspect.py b/src/nexusx/federation/introspect.py index f04a24c7..ee9bb8d8 100644 --- a/src/nexusx/federation/introspect.py +++ b/src/nexusx/federation/introspect.py @@ -309,13 +309,12 @@ def serialize_dto_introspection(er_manager: Any) -> DTOIntrospectionResponse: arg_name=f"{join_key}_list", arg_type="", ) - # γ remote top-N (specs/016 Phase 2): a DTO-level __pagination_orders__ - # (BatchPageConfig) exposes the order profiles the member can sort by. + # γ remote top-N (specs/020): the order profile is the source entity's + # single __pagination_orders__ — the DTO inherits the entity's own sort. # Validated against the base entity's physical columns via - # _resolve_page_orders — same gate as entity __pagination_orders__, - # so a DTO order field that isn't a base-entity column fails fast. - cfg = getattr(dto, "__pagination_orders__", None) - if cfg is not None and source is not None: + # _resolve_page_orders, so an order field that isn't a column fails fast. + cfg = getattr(source, "__pagination_orders__", None) if source is not None else None + if cfg is not None: from nexusx.federation.contract import ( BatchPageCapability, PageOrderDescriptor, diff --git a/src/nexusx/federation/manager.py b/src/nexusx/federation/manager.py index 92fa331c..d330b933 100644 --- a/src/nexusx/federation/manager.py +++ b/src/nexusx/federation/manager.py @@ -496,12 +496,14 @@ def _check_target( if br is None: raise FederationError( f"Type {target!r} does not expose batch root {entry!r}; " - f"member must generate it via AutoQueryConfig." + f"member must declare it on the entity via __federation_keys__ " + f"(specs/020)." ) if not br.arg_name: raise FederationError( f"Batch root {entry!r} on {target!r} has no determinable argument " - f"name; the member must generate it via AutoQueryConfig.batch_keys." + f"name; the member must declare the join field in " + f"__federation_keys__ (specs/020)." ) if pagination: _validate_page_capability(target, br) diff --git a/src/nexusx/loader/__init__.py b/src/nexusx/loader/__init__.py index bdab06b7..cf0f9a6e 100644 --- a/src/nexusx/loader/__init__.py +++ b/src/nexusx/loader/__init__.py @@ -1,12 +1,12 @@ """Loader module - DataLoader factories and entity-relationship management.""" +from nexusx.loader.composed import ComposedErManager from nexusx.loader.pagination import ( PageArgs, PageLoadCommand, Pagination, create_result_type, ) -from nexusx.loader.composed import ComposedErManager from nexusx.loader.registry import ErManager, LoaderRegistry, RelationshipInfo __all__ = [ diff --git a/src/nexusx/loader/registry.py b/src/nexusx/loader/registry.py index f562cad7..3326f095 100644 --- a/src/nexusx/loader/registry.py +++ b/src/nexusx/loader/registry.py @@ -124,25 +124,24 @@ def _extract_sort_field(order_by: Any) -> str: def _resolve_local_page_capability( - entity_kls: type[SQLModel], - rel_name: str, target_entity: type[SQLModel], ) -> tuple[Any, Any]: - """Build a ``BatchPageCapability`` for a local paginated relationship from - the entity's class-level ``__pagination_orders__`` declaration. + """Build a ``BatchPageCapability`` for a local paginated relationship. - ``__pagination_orders__`` maps ORM relation name → ``BatchPageConfig`` (the - same container federation ``batch_pages`` uses). Profiles are validated with - federation's ``_resolve_page_orders`` (enum-safe names, single-column, SQL - column, direction, nullable nulls, default∈keys) — fail-fast at startup. + specs/020: the order profile belongs to the SORTED object — the + relationship's TARGET (e.g. Comment), not the owner (Review). Comment's sort + is declared once on Comment.__pagination_orders__ and reused by every owner + that references it, so we read target_entity.__pagination_orders__ here. + Profiles are validated with federation's ``_resolve_page_orders`` (enum-safe + names, single-column, SQL column, direction, nullable nulls, default∈keys) — + fail-fast at startup. Returns ``(capability, resolved_orders)`` — ``capability`` is the descriptor (names only) used for schema rendering; ``resolved_orders`` carries the physical ``OrderTerm``s the page_loader needs to build ORDER BY. Both None - when the relationship has no profile declaration (falls back to sort_field). - specs/015. + when the target has no profile (falls back to sort_field). specs/015. """ - cfg = getattr(entity_kls, "__pagination_orders__", {}).get(rel_name) + cfg = getattr(target_entity, "__pagination_orders__", None) if cfg is None: return None, None from nexusx.federation.contract import BatchPageCapability, PageOrderDescriptor @@ -253,7 +252,7 @@ def _inspect_relationships( else: # List relationship — create regular + optional paginated loader page_capability, page_orders_resolved = _resolve_local_page_capability( - entity_kls, rel_name, target_entity + target_entity ) sort_field = None page_loader = None @@ -320,7 +319,7 @@ def _inspect_relationships( fk_field = source_col.key page_capability, m2m_orders_resolved = _resolve_local_page_capability( - entity_kls, rel_name, target_entity + target_entity ) sort_field = None page_loader = None diff --git a/src/nexusx/resolver.py b/src/nexusx/resolver.py index b2979155..db34e199 100644 --- a/src/nexusx/resolver.py +++ b/src/nexusx/resolver.py @@ -586,11 +586,12 @@ def _get_loader( if isinstance(node, BaseModel): dto_loader_cls = self._registry.get_dto_loader(type(node), loader_name) if dto_loader_cls is not None: - # γ remote: Paged default (field) + caller context override → - # merged → per-params split + side-channel into POST body. + # γ remote: Paged default (field) is fixed — caller context no + # longer overrides it (pagination params are declarative; runtime + # input belongs on the UseCase method signature, not Resolver + # context). per-params split + side-channel into POST body. paged_default = getattr(type(node), "__paged_fields__", {}).get(loader_name) - paged_caller = self._extract_page_params(loader_name) - merged = self._merge_paged(paged_default, paged_caller) + merged = paged_default # γ DTO loader-prep primitive (specs/018 US4 / T023): consolidate # the per-params loader + page-params side-channel into one place # so ``set_dto_page_params`` has a single caller (prepare_dto_loader). @@ -656,21 +657,6 @@ def _get_or_create_fn_loader(self, fn: Callable) -> DataLoader: self._loader_cache[fn] = DataLoader(batch_load_fn=fn) return self._loader_cache[fn] - def _extract_page_params(self, field_name: str) -> Any: - """Read caller page params from Resolver ``context`` (set by the use - case method入口 from query args). Per-field dict first - (``context[field]``), then global (``context``). Returns an override - carrying only caller-provided values, else None (→ no override). - """ - from nexusx.loader.pagination import _PagedOverride - - if not self._context: - return None - raw = self._context.get(field_name) - if not isinstance(raw, dict): - raw = self._context - return _PagedOverride.from_dict(raw) - @staticmethod def _merge_paged(default: Any, caller: Any) -> Any: """Merge a field's Paged default with caller-context override. @@ -1641,14 +1627,13 @@ async def _batch_auto_load( ) else: type_key = generate_type_key_from_dto(first_dto) if first_dto else None - # Caller page params (from Resolver context): if present and a - # page_loader exists, slice per-parent via PageLoadCommand. - # per-params split (params_key) keeps different params in - # different batches (aiodataloader: one instance = one batch). - # Paged default (field Annotated metadata) + caller override. + # Paged default (field Annotated metadata) is fixed — caller + # context no longer overrides it (declarative pagination; runtime + # input lives on the UseCase method signature). per-params split + # (params_key) keeps different field defaults in different batches + # (aiodataloader: one instance = one batch). paged_default = getattr(type(first_node), "__paged_fields__", {}).get(rel_name) - paged_caller = self._extract_page_params(rel_name) - merged = self._merge_paged(paged_default, paged_caller) + merged = paged_default use_page_loader = ( merged is not None and first_rel.page_loader is not None diff --git a/src/nexusx/standard_queries.py b/src/nexusx/standard_queries.py index c4748de9..c7aaa921 100644 --- a/src/nexusx/standard_queries.py +++ b/src/nexusx/standard_queries.py @@ -109,8 +109,6 @@ def __init__( generate_by_id: bool = True, generate_by_filter: bool = True, enabled: bool = True, - batch_keys: dict[str, list[str]] | None = None, - batch_pages: dict[str, dict[str, BatchPageConfig]] | None = None, ): """Initialize the auto query configuration. @@ -119,13 +117,6 @@ def __init__( generate_by_id: Whether to generate by_id query. generate_by_filter: Whether to generate by_filter query. enabled: Whether standard queries are enabled. - batch_keys: Per-entity batch lookup fields for federation, mapping - ``{EntityName: [field, ...]}``. For each field a - ``by__in(values: list)`` batch query root is generated - (``where field.in_(values)``). Generally useful beyond federation. - batch_pages: Explicit member-side pagination capabilities, mapping - ``{EntityName: {batch_field: BatchPageConfig(...)}}``. Each - configured field generates ``page_by__in``. Note: ``session_factory`` was removed from this constructor — pass it to @@ -154,8 +145,6 @@ def __init__( self.generate_by_id = generate_by_id self.generate_by_filter = generate_by_filter self.enabled = enabled - self.batch_keys = batch_keys or {} - self.batch_pages = batch_pages or {} async def _create_session_context(session_factory: Any) -> Any: @@ -761,49 +750,46 @@ def add_standard_queries( ) entity.by_filter = by_filter_method - # Batch lookup roots (by__in) — used by federation RemoteLoader. - for field_name in config.batch_keys.get(entity.__name__, []): - method_name = f"by_{field_name}_in" + # Federation batch roots — driven by entity.__federation_keys__ (which + # fields are batch entry points) + a single entity-level + # __pagination_orders__ (how the entity's own rows sort). The two are + # ORTHOGONAL: federation_keys picks the entry field, __pagination_orders__ + # picks the sort — they no longer couple via a per-key dict. Every + # federation key gets by__in; if the entity declares + # __pagination_orders__, every federation key additionally gets + # page_by__in (paginated), all sharing that one sort profile. + # specs/020. + fed_keys = getattr(entity, "__federation_keys__", []) or [] + page_config = getattr(entity, "__pagination_orders__", None) + for field_name in fed_keys: if field_name not in entity.model_fields: msg = ( - f"AutoQueryConfig.batch_keys field {field_name!r} is not a " - f"column on {entity.__name__}" - ) - raise ValueError(msg) - field_type = _unwrap_optional_type(entity.model_fields[field_name].annotation) - if not hasattr(entity, method_name): - setattr( - entity, - method_name, - _create_by_keys_in_query(entity, session_factory, field_name, field_type), - ) - - # Explicit member-side pagination capabilities. - for field_name, page_config in config.batch_pages.get( - entity.__name__, {} - ).items(): - page_method_name = f"page_by_{field_name}_in" - if field_name not in entity.model_fields: - msg = ( - f"AutoQueryConfig.batch_pages field {field_name!r} is not a " - f"column on {entity.__name__}" + f"{entity.__name__}.__federation_keys__ field {field_name!r} " + f"is not a column on {entity.__name__}" ) raise ValueError(msg) field_type = _unwrap_optional_type( entity.model_fields[field_name].annotation ) - if not hasattr(entity, page_method_name): + method_name = f"by_{field_name}_in" + if not hasattr(entity, method_name): setattr( entity, - page_method_name, - _create_page_by_keys_in_query( - entity, - session_factory, - field_name, - field_type, - page_config, + method_name, + _create_by_keys_in_query( + entity, session_factory, field_name, field_type, ), ) + if page_config is not None: + page_method_name = f"page_by_{field_name}_in" + if not hasattr(entity, page_method_name): + setattr( + entity, + page_method_name, + _create_page_by_keys_in_query( + entity, session_factory, field_name, field_type, page_config, + ), + ) # ────────────────────────────────────────────────────────────────────── @@ -1006,17 +992,17 @@ def add_dto_batch_roots(er_manager: Any) -> None: if join_type_name is None or join_type_name not in _SUPPORTED_JOIN_TYPES: supported = ", ".join(sorted(_SUPPORTED_JOIN_TYPES)) raise ValueError( - f"{dto_cls.__name__} federation_join_key {join_key!r} has " + f"{dto_cls.__name__} federation join key {join_key!r} has " f"unsupported type {join_type_name!r} on {base_entity.__name__}; " f"DTO federation serializes keys over JSON — supported join-key " f"types: {supported}." ) - # specs/016 Phase 2: resolve a DTO-level __pagination_orders__ - # (BatchPageConfig) into physical OrderTerms, validated against the - # base entity's columns (fail-fast at startup — same gate as entity - # __pagination_orders__). Fed to the batch root for per-parent top-N - # when the mounter sends order+limit. - cfg = getattr(dto_cls, "__pagination_orders__", None) + # specs/020: the DTO's order profile is the source entity's single + # __pagination_orders__ (shared across all its federation keys — the DTO + # inherits the entity's own sort, no per-key lookup). Resolved into + # physical OrderTerms, validated against the base entity's columns + # (fail-fast at startup). Fed to the batch root for per-parent top-N. + cfg = getattr(base_entity, "__pagination_orders__", None) page_orders_resolved = None default_order = None if cfg is not None: diff --git a/src/nexusx/subset.py b/src/nexusx/subset.py index aaf00210..11d8dc52 100644 --- a/src/nexusx/subset.py +++ b/src/nexusx/subset.py @@ -37,7 +37,6 @@ class PostSummary(DefineSubset): from sqlmodel import SQLModel from nexusx.resolver import POST_PREFIX, RESOLVE_PREFIX # noqa: F401 -from nexusx.utils.type_utils import get_fk_fields # ────────────────────────────────────────────────────────── # Constants @@ -428,11 +427,11 @@ class SubsetConfig(BaseModel): expose_as: list[tuple[str, str]] | None = None send_to: list[tuple[str, str | tuple[str, ...]]] | None = None # Federation (γ-path): expose this DTO as a member public DTO. - # federation_public=True requires federation_join_key (validated against - # the generated DTO's subset fields at class creation — see - # _validate_federation_config). + # federation_public=True requires the source entity to declare + # __federation_keys__; the DTO's join key is derived from it (auto when the + # entity has a single federation key; otherwise select via federation_key). federation_public: bool = False - federation_join_key: str | None = None + federation_key: str | None = None @model_validator(mode="after") def _validate_config(self) -> SubsetConfig: @@ -451,16 +450,19 @@ def _validate_federation_config( ) -> None: """Validate SubsetConfig federation params against the generated DTO. - When ``federation_public=True``: - * ``federation_join_key`` must be one of the DTO's subset fields (so the - member batch root can ``WHERE join_key IN [...]`` against the base - entity's column — a Resolver-computed field would not be SQL-filterable). - * If omitted, it's auto-derived when the subset contains exactly one FK - field (no ambiguity). Zero/multiple FKs → must specify explicitly. - - Fail-fast at class creation: a mistyped or computed join_key is caught - here, not at federation query time. Only SubsetConfig can declare a DTO - public (the tuple syntax carries no federation params). + When ``federation_public=True`` (specs/020): the join key is derived from + the source entity's ``__federation_keys__`` — the DTO no longer declares the + key value itself. + * Source entity must declare ``__federation_keys__``. + * Single federation key → used automatically. + * Multiple federation keys → select one via ``federation_key`` (it names a + key the entity already declared, not a new value). + * The resolved key must be among the DTO's subset fields (so the member + batch root can ``WHERE join_key IN [...]`` against the base entity's + column — a Resolver-computed field would not be SQL-filterable). + + Fail-fast at class creation. Only SubsetConfig can declare a DTO public + (the tuple syntax carries no federation params). """ if not isinstance(subset_info, SubsetConfig) or not subset_info.federation_public: return @@ -468,30 +470,39 @@ def _validate_federation_config( subset_fields = getattr(subset_class, "__subset_fields__", None) field_set = set(subset_fields) if subset_fields else set(subset_class.model_fields) - join_key = subset_info.federation_join_key - if not join_key: - # Auto-derive: if the subset contains exactly one FK field, use it as - # the federation join key (no ambiguity, no need to repeat the name). - # Zero or multiple FKs in the subset → must specify explicitly. - entity = subset_info.kls - fk_in_subset = [f for f in get_fk_fields(entity) if f in field_set] - if len(fk_in_subset) == 1: - join_key = fk_in_subset[0] - subset_info.federation_join_key = join_key - else: + entity = subset_info.kls + entity_fed_keys = list(getattr(entity, "__federation_keys__", []) or []) + if not entity_fed_keys: + raise ValueError( + f"{subset_class.__name__}: federation_public=True requires the " + f"source entity {entity.__name__} to declare __federation_keys__ " + f"(specs/020)." + ) + + selector = subset_info.federation_key + if selector: + if selector not in entity_fed_keys: raise ValueError( - f"{subset_class.__name__}: federation_public=True requires " - f"federation_join_key. Could not auto-derive — subset has " - f"{len(fk_in_subset)} FK field(s) ({sorted(fk_in_subset)}); " - f"specify federation_join_key explicitly." + f"{subset_class.__name__}: federation_key {selector!r} is not " + f"in {entity.__name__}.__federation_keys__ {entity_fed_keys}." ) + join_key = selector + elif len(entity_fed_keys) == 1: + join_key = entity_fed_keys[0] + subset_info.federation_key = join_key # back-fill for stamping + else: + raise ValueError( + f"{subset_class.__name__}: {entity.__name__} has multiple " + f"federation keys {entity_fed_keys}; specify " + f"SubsetConfig(federation_key=...) to select one." + ) if join_key not in field_set: raise ValueError( - f"{subset_class.__name__}: federation_join_key '{join_key}' is not " + f"{subset_class.__name__}: federation join key {join_key!r} is not " f"a subset field of the DTO (subset fields: {sorted(field_set)}). " - f"join_key must be a field sourced from the base entity so the " - f"member batch root can filter by it." + f"The join key must be among the subset fields so the member batch " + f"root can filter by it." ) @@ -508,7 +519,10 @@ def _stamp_federation_metadata( """ if isinstance(subset_info, SubsetConfig): subset_class.__federation_public__ = subset_info.federation_public - subset_class.__federation_join_key__ = subset_info.federation_join_key + # __federation_join_key__ is the entity-derived join key (resolved in + # _validate_federation_config from the source entity's + # __federation_keys__); the DTO no longer declares the value itself. + subset_class.__federation_join_key__ = subset_info.federation_key else: subset_class.__federation_public__ = False subset_class.__federation_join_key__ = None @@ -1060,12 +1074,10 @@ def _create_subset_class( subset_class.__subset_fields__ = list(subset_fields) subset_class.__subset_auto_excluded__ = auto_excluded or set() - # Preserve a DTO-level __pagination_orders__ (BatchPageConfig) so - # serialize_dto_introspection can expose it for γ remote top-N. - # create_model drops custom class attrs, so re-attach from namespace. - pagination_orders = namespace.get("__pagination_orders__") - if pagination_orders is not None: - subset_class.__pagination_orders__ = pagination_orders + # specs/020 US2: a DTO no longer carries its own __pagination_orders__ — + # the member batch root reads the order profile from the source entity's + # __pagination_orders__[join_key]. create_model drops custom class attrs + # anyway, so a lingering DTO-level declaration is silently ignored. return subset_class diff --git a/src/nexusx/utils/type_utils.py b/src/nexusx/utils/type_utils.py index d3f835aa..e446324a 100644 --- a/src/nexusx/utils/type_utils.py +++ b/src/nexusx/utils/type_utils.py @@ -82,8 +82,8 @@ def get_fk_fields(entity: type) -> set[str]: Detects ``field_info.foreign_key`` (str) and ``metadata`` entries carrying a str ``foreign_key`` — SQLModel/SQLAlchemy column FK markers. Shared by - query_executor (output filtering), subset (federation_join_key - auto-derivation) and standard_queries (PK-vs-FK filtering). + query_executor (output filtering), subset (FK auto-include into + DTO subset fields) and standard_queries (PK-vs-FK filtering). """ fks: set[str] = set() if not hasattr(entity, "model_fields"): diff --git a/tests/test_composed_federation.py b/tests/test_composed_federation.py index a990e2b1..6961dcf5 100644 --- a/tests/test_composed_federation.py +++ b/tests/test_composed_federation.py @@ -42,6 +42,7 @@ class _CfReviewsBase(SQLModel): class CfReview(_CfReviewsBase, table=True): __tablename__ = "cf_composed_review" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -149,7 +150,7 @@ async def composed_federation_world(): reviews_h = GraphQLHandler( base=_CfReviewsBase, session_factory=reviews_sf, - auto_query_config=AutoQueryConfig(batch_keys={"CfReview": ["product_id"]}), + auto_query_config=AutoQueryConfig(), service_name="cfreviews", ) composite_app = Starlette(routes=[ diff --git a/tests/test_dto_batch_root.py b/tests/test_dto_batch_root.py index f05a5c07..88d71af9 100644 --- a/tests/test_dto_batch_root.py +++ b/tests/test_dto_batch_root.py @@ -22,6 +22,7 @@ class _BRBase(SQLModel): class _Review(_BRBase, table=True): __tablename__ = "dto_br_review" + __federation_keys__ = ["product_id"] id: int | None = SQLField(default=None, primary_key=True) product_id: int title: str @@ -33,7 +34,6 @@ class _ReviewDTO(DefineSubset): kls=_Review, fields=("title", "rating", "product_id"), federation_public=True, - federation_join_key="product_id", ) # 计算字段(member Resolver 加工) rating_double: int | None = None @@ -135,14 +135,14 @@ def test_public_dto_join_key_validated_at_class_creation(): """join_key 不在 DTO subset 字段里 → metaclass 期 fail-fast(先于 add_dto_batch_roots)。""" import pytest as _pytest - with _pytest.raises(ValueError, match="federation_join_key"): + with _pytest.raises(ValueError, match="federation_key"): class _BadDTO(DefineSubset): __subset__ = SubsetConfig( kls=_Review, fields=("title", "rating", "product_id"), federation_public=True, - federation_join_key="nonexistent", + federation_key="nonexistent", # 不在 _Review.__federation_keys__ ) @@ -151,6 +151,7 @@ def test_public_dto_join_key_auto_derived_single_fk(): class _FkEnt(_BRBase, table=True): __tablename__ = "dto_br_autofk" + __federation_keys__ = ["product_id"] id: int | None = SQLField(default=None, primary_key=True) product_id: int = SQLField(foreign_key="dto_br_review.id") title: str @@ -160,7 +161,7 @@ class _DTOAuto(DefineSubset): kls=_FkEnt, fields=("title", "product_id"), federation_public=True, - # 无 federation_join_key → 自动 product_id(单 FK ∈ subset) + # 单 federation key → 自动 product_id(_FkEnt.__federation_keys__) ) assert _DTOAuto.__federation_join_key__ == "product_id" @@ -171,11 +172,12 @@ def test_public_dto_join_key_auto_ambiguous_multiple_fks(): class _MultiFk(_BRBase, table=True): __tablename__ = "dto_br_multifk" + __federation_keys__ = ["a_id", "b_id"] id: int | None = SQLField(default=None, primary_key=True) a_id: int = SQLField(foreign_key="dto_br_review.id") b_id: int = SQLField(foreign_key="dto_br_review.id") - with pytest.raises(ValueError, match="auto-derive"): + with pytest.raises(ValueError, match="multiple federation keys"): class _DTOMulti(DefineSubset): __subset__ = SubsetConfig( @@ -197,6 +199,7 @@ def test_add_dto_batch_roots_rejects_unsupported_join_key_type(): class _DecReview(_BRBase, table=True): __tablename__ = "dto_br_decimal" + __federation_keys__ = ["amount"] id: int | None = SQLField(default=None, primary_key=True) amount: Decimal | None = SQLField(default=None) @@ -205,7 +208,6 @@ class _DecDTO(DefineSubset): kls=_DecReview, fields=("amount",), federation_public=True, - federation_join_key="amount", ) er = ErManager( @@ -226,6 +228,7 @@ def test_add_dto_batch_roots_accepts_uuid_join_key(): class _UuidReview(_BRBase, table=True): __tablename__ = "dto_br_uuid" + __federation_keys__ = ["owner_id"] id: UUID | None = SQLField(default=None, primary_key=True) owner_id: UUID | None = SQLField(default=None) @@ -234,7 +237,6 @@ class _UuidDTO(DefineSubset): kls=_UuidReview, fields=("owner_id",), federation_public=True, - federation_join_key="owner_id", ) er = ErManager( diff --git a/tests/test_dto_failure_paths.py b/tests/test_dto_failure_paths.py index f40dc5f2..164f0e63 100644 --- a/tests/test_dto_failure_paths.py +++ b/tests/test_dto_failure_paths.py @@ -26,6 +26,7 @@ class _WireBase(SQLModel): class _WireProduct(_WireBase, table=True): __tablename__ = "dto_wire_product" + __federation_keys__ = ["id"] id: int | None = SQLField(default=None, primary_key=True) name: str @@ -42,7 +43,6 @@ class _PublicWireDTO(DefineSubset): kls=_WireProduct, fields=("id", "name"), federation_public=True, - federation_join_key="id", ) diff --git a/tests/test_dto_federation_e2e.py b/tests/test_dto_federation_e2e.py index d2f61a97..ee64699e 100644 --- a/tests/test_dto_federation_e2e.py +++ b/tests/test_dto_federation_e2e.py @@ -50,6 +50,7 @@ class _UsersBase(SQLModel): class User(_UsersBase, table=True): __tablename__ = "dto_e2e_user" + __federation_keys__ = ["id"] id: int | None = SQLField(default=None, primary_key=True) name: str @@ -61,6 +62,7 @@ class _ReviewsBase(SQLModel): class Review(_ReviewsBase, table=True): __tablename__ = "dto_e2e_review" + __federation_keys__ = ["product_id", "author_id"] id: int | None = SQLField(default=None, primary_key=True) product_id: int author_id: int @@ -81,7 +83,7 @@ class ReviewDTO(DefineSubset): kls=Review, fields=("title", "rating", "product_id"), federation_public=True, - federation_join_key="product_id", + federation_key="product_id", ) rating_double: int | None = None author_name: str | None = None @@ -202,12 +204,12 @@ def sf(k): users_h = GraphQLHandler( base=_UsersBase, session_factory=sf("u"), - auto_query_config=AutoQueryConfig(batch_keys={"User": ["id"]}), + auto_query_config=AutoQueryConfig(), service_name="users", ) reviews_h = GraphQLHandler( base=_ReviewsBase, session_factory=sf("r"), - auto_query_config=AutoQueryConfig(batch_keys={"Review": ["product_id", "author_id"]}), + auto_query_config=AutoQueryConfig(), service_name="reviews", expose_mounted_endpoints=True, dto_classes=[ReviewDTO], diff --git a/tests/test_dto_introspect.py b/tests/test_dto_introspect.py index 25d6798b..569e7a76 100644 --- a/tests/test_dto_introspect.py +++ b/tests/test_dto_introspect.py @@ -28,6 +28,7 @@ class _IBase(SQLModel): class _Product(_IBase, table=True): __tablename__ = "dto_introspect_product" + __federation_keys__ = ["id"] id: int | None = SQLField(default=None, primary_key=True) name: str @@ -38,7 +39,6 @@ class _PubDTO(DefineSubset): kls=_Product, fields=("name",), federation_public=True, - federation_join_key="id", ) name_upper: str | None = None diff --git a/tests/test_dto_paged.py b/tests/test_dto_paged.py index 75b8cdee..c946638f 100644 --- a/tests/test_dto_paged.py +++ b/tests/test_dto_paged.py @@ -1,8 +1,8 @@ -"""specs/016 Paged 字段声明分页(完整四参 limit/offset/order/direction + caller 覆盖)。 +"""specs/016 Paged 字段声明分页(完整四参 limit/offset/order/direction)。 ``Paged(...)`` 挂 ER relationship 字段(``Annotated[list[Target], Paged(...)]``), -提供默认分页参数;caller ``Resolver(context=...)`` 可逐字段覆盖。映射 PO2M 完整 -分页(ROW_NUMBER BETWEEN offset+1 AND offset+limit,ORDER BY )。 +提供固定分页参数(声明固化,运行时不可被 Resolver context 覆盖)。映射 PO2M 完整分页 +(ROW_NUMBER BETWEEN offset+1 AND offset+limit,ORDER BY )。 种子: T1 三条 comment(likes 5/3/1)。MOST_LIKED(likes desc nulls_last)=[C5,C3,C1]。 """ @@ -30,6 +30,11 @@ class DPBase(SQLModel): class DPComment(DPBase, table=True): __tablename__ = "dpp_comment" + # specs/020: Comment's own sort — read when DPThread.comments is paginated. + __pagination_orders__ = BatchPageConfig( + default_order="MOST_LIKED", + orders={"MOST_LIKED": PageOrder([OrderTerm("likes", "desc", nulls="last")])}, + ) id: int | None = Field(default=None, primary_key=True) text: str likes: int | None = Field(default=None) @@ -45,14 +50,7 @@ class DPThread(DPBase, table=True): back_populates="thread", sa_relationship_kwargs={"order_by": "DPComment.id"}, ) - __pagination_orders__ = { - "comments": BatchPageConfig( - default_order="MOST_LIKED", - orders={ - "MOST_LIKED": PageOrder([OrderTerm("likes", "desc", nulls="last")]), - }, - ), - } + # specs/020: comments order profile lives on DPComment (the sorted object). class DPCommentDTO(DefineSubset): @@ -127,31 +125,6 @@ async def test_paged_default_top_n(handler): assert [c.text for c in comments] == ["C5", "C3"] # likes desc: 5, 3 -@pytest.mark.asyncio -async def test_caller_overrides_paged_default(handler): - """caller context {limit:1} 覆盖 Paged 默认 limit=2 → top-1。 - - Paged 默认 limit=2,caller 传 limit=1 → merged limit=1(caller 赢)。 - static-ness 解:Paged 是默认,caller 可覆盖。 - """ - ResolverCls = handler._er_manager.create_resolver() - resolver = ResolverCls(context={"limit": 1}) - resolved = await resolver.resolve([DPThreadDTO(id=1, title="T1")]) - assert [c.text for c in resolved[0].comments] == ["C5"] # top-1 - - -@pytest.mark.asyncio -async def test_paged_offset_second_page(handler): - """caller context {offset:1} → 第 2 页(merged offset=1 覆盖 Paged 默认 0)。 - - MOST_LIKED 全序 [C5,C3,C1];offset=1 limit=2 → rn BETWEEN 2 AND 3 → [C3,C1]。 - """ - ResolverCls = handler._er_manager.create_resolver() - resolver = ResolverCls(context={"offset": 1}) - resolved = await resolver.resolve([DPThreadDTO(id=1, title="T1")]) - assert [c.text for c in resolved[0].comments] == ["C3", "C1"] - - @pytest.mark.asyncio async def test_paged_order_none_uses_entity_default(handler): """Paged(order=None) + caller 不传 → 用 entity default_order(MOST_LIKED)。""" @@ -185,19 +158,3 @@ async def test_paged_multi_parent_batch(handler): by_id = {t.id: t for t in resolved} assert [c.text for c in by_id[1].comments] == ["C5", "C3"] assert [c.text for c in by_id[2].comments] == ["C4", "C2"] - - -@pytest.mark.asyncio -async def test_caller_only_no_paged_default(handler): - """DTO 无 Paged + caller context → caller 驱动(back-compat,Paged default None)。 - - 没有 Paged 默认时,caller context 单独驱动分页(merged = merge(None, caller) = caller)。 - """ - class _DTO(DefineSubset): - __subset__ = (DPThread, ("id", "title")) - comments: list[DPCommentDTO] = Field(default_factory=list) # 无 Paged - - ResolverCls = handler._er_manager.create_resolver() - resolver = ResolverCls(context={"limit": 1, "order": "MOST_LIKED"}) - resolved = await resolver.resolve([_DTO(id=1, title="T1")]) - assert [c.text for c in resolved[0].comments] == ["C5"] # caller limit=1 diff --git a/tests/test_dto_paged_remote.py b/tests/test_dto_paged_remote.py index ffcfecc4..09ef526a 100644 --- a/tests/test_dto_paged_remote.py +++ b/tests/test_dto_paged_remote.py @@ -44,6 +44,11 @@ class _ReviewsBase(SQLModel): class Review(_ReviewsBase, table=True): __tablename__ = "dprem_review" + __federation_keys__ = ["product_id"] + __pagination_orders__ = BatchPageConfig( + default_order="TOP", + orders={"TOP": PageOrder([OrderTerm("rating", "desc")])}, + ) id: int | None = SQLField(default=None, primary_key=True) product_id: int title: str @@ -66,11 +71,6 @@ class ReviewDTO(DefineSubset): kls=Review, fields=("title", "rating", "product_id"), federation_public=True, - federation_join_key="product_id", - ) - __pagination_orders__ = BatchPageConfig( - default_order="TOP", - orders={"TOP": PageOrder([OrderTerm("rating", "desc")])}, ) rating_double: int | None = None @@ -133,7 +133,7 @@ def sf(k): reviews_h = GraphQLHandler( base=_ReviewsBase, session_factory=sf("r"), - auto_query_config=AutoQueryConfig(batch_keys={"Review": ["product_id"]}), + auto_query_config=AutoQueryConfig(), service_name="reviews", expose_mounted_endpoints=True, dto_classes=[ReviewDTO], ) @@ -200,21 +200,6 @@ async def test_remote_paged_member_only_resolves_top_n(federation): assert _resolve_count == 2, f"member resolved {_resolve_count} DTOs, expected 2 (top-N)" -@pytest.mark.asyncio -async def test_remote_paged_caller_overrides(federation): - """caller context {limit:1} 覆盖 Paged 默认 limit=2 → top-1。""" - catalog_h = federation["catalog"] - async with catalog_h.session_factory() as s: - products = (await s.exec(select(Product))).all() - dtos = [ProductDTO(id=p.id, name=p.name) for p in products] - - ResolverCls = catalog_h._er_manager.create_resolver() - resolved = await ResolverCls(context={"limit": 1}).resolve(dtos) - - assert len(resolved[0].reviews) == 1 - assert resolved[0].reviews[0].rating == 5 # top-1 - - @pytest.mark.asyncio async def test_order_without_limit_slices_ordered(federation): """F1: Paged(order=...) 无 limit → 仍走 top-N 切片(默认页大小), 不再静默 diff --git a/tests/test_dto_review_fixes.py b/tests/test_dto_review_fixes.py index 8138990c..88775ebd 100644 --- a/tests/test_dto_review_fixes.py +++ b/tests/test_dto_review_fixes.py @@ -85,6 +85,7 @@ class _JoinProduct(SQLModel, table=True): class _JoinReview(SQLModel, table=True): __tablename__ = "dto_review_join_review" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int = Field(foreign_key="dto_review_join_product.id") @@ -96,7 +97,6 @@ class _HiddenJoinDTO(DefineSubset): kls=_JoinReview, fields=("title",), federation_public=True, - federation_join_key="product_id", ) diff --git a/tests/test_federation_cycle.py b/tests/test_federation_cycle.py index b06664d6..37d6d873 100644 --- a/tests/test_federation_cycle.py +++ b/tests/test_federation_cycle.py @@ -40,6 +40,7 @@ class _UsersBase(SQLModel): class CycUser(_UsersBase, table=True): __tablename__ = "cyc_user" + __federation_keys__ = ["id"] id: int | None = Field(default=None, primary_key=True) name: str __relationships__ = [ @@ -56,6 +57,7 @@ class _PostsBase(SQLModel): class CycPost(_PostsBase, table=True): __tablename__ = "cyc_post" + __federation_keys__ = ["author_id", "id"] id: int | None = Field(default=None, primary_key=True) author_id: int title: str @@ -107,12 +109,12 @@ def sf(k): users_h = GraphQLHandler( base=_UsersBase, session_factory=sf("users"), - auto_query_config=AutoQueryConfig(batch_keys={"CycUser": ["id"]}), + auto_query_config=AutoQueryConfig(), service_name="svcUsers", expose_mounted_endpoints=True, ) posts_h = GraphQLHandler( base=_PostsBase, session_factory=sf("posts"), - auto_query_config=AutoQueryConfig(batch_keys={"CycPost": ["author_id", "id"]}), + auto_query_config=AutoQueryConfig(), service_name="svcPosts", expose_mounted_endpoints=True, ) composite = Starlette(routes=[ diff --git a/tests/test_federation_deep_chain.py b/tests/test_federation_deep_chain.py index 71ec2d5f..bc7a3b5d 100644 --- a/tests/test_federation_deep_chain.py +++ b/tests/test_federation_deep_chain.py @@ -48,6 +48,7 @@ class DCUserConfig(DCUsersBase, table=True): class DCUser(DCUsersBase, table=True): __tablename__ = "dc_deep_user" + __federation_keys__ = ["id"] id: int | None = Field(default=None, primary_key=True) name: str config: DCUserConfig | None = Relationship(sa_relationship_kwargs={"uselist": False}) @@ -75,6 +76,7 @@ class DCComment(DCReviewsBase, table=True): class DCReview(DCReviewsBase, table=True): __tablename__ = "dc_deep_review" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -138,12 +140,12 @@ def sf(k): users_h = GraphQLHandler( base=DCUsersBase, session_factory=sf("users"), - auto_query_config=AutoQueryConfig(batch_keys={"DCUser": ["id"]}), + auto_query_config=AutoQueryConfig(), service_name="users", ) reviews_h = GraphQLHandler( base=DCReviewsBase, session_factory=sf("reviews"), - auto_query_config=AutoQueryConfig(batch_keys={"DCReview": ["product_id"]}), + auto_query_config=AutoQueryConfig(), service_name="reviews", expose_mounted_endpoints=True, # let catalog discover users transitively ) diff --git a/tests/test_federation_definesubset.py b/tests/test_federation_definesubset.py index 750139be..c92ff98a 100644 --- a/tests/test_federation_definesubset.py +++ b/tests/test_federation_definesubset.py @@ -40,12 +40,14 @@ class _CB(SQLModel): class DSUser(_UB, table=True): __tablename__ = "fed_ds_user" + __federation_keys__ = ["id"] id: int | None = Field(default=None, primary_key=True) name: str class DSReview(_RB, table=True): __tablename__ = "fed_ds_review" + __federation_keys__ = ["product_id", "author_id"] id: int | None = Field(default=None, primary_key=True) product_id: int author_id: int @@ -116,12 +118,12 @@ def sf(k): users_h = GraphQLHandler( base=_UB, session_factory=sf("u"), - auto_query_config=AutoQueryConfig(batch_keys={"DSUser": ["id"]}), + auto_query_config=AutoQueryConfig(), service_name="users", ) reviews_h = GraphQLHandler( base=_RB, session_factory=sf("r"), - auto_query_config=AutoQueryConfig(batch_keys={"DSReview": ["product_id", "author_id"]}), + auto_query_config=AutoQueryConfig(), service_name="reviews", expose_mounted_endpoints=True, ) diff --git a/tests/test_federation_e2e.py b/tests/test_federation_e2e.py index 700b0c90..3fc6f7a1 100644 --- a/tests/test_federation_e2e.py +++ b/tests/test_federation_e2e.py @@ -54,6 +54,7 @@ class FedUser(ReviewsBase, table=True): class FedReview(ReviewsBase, table=True): __tablename__ = "fed_e2e_review" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -139,7 +140,7 @@ async def _build_catalog_and_transport(): await _ensure_seed() reviews_handler = GraphQLHandler( base=ReviewsBase, session_factory=_rev_sf, - auto_query_config=AutoQueryConfig(batch_keys={"FedReview": ["product_id"]}), + auto_query_config=AutoQueryConfig(), service_name="reviews", ) reviews_app = build_federable_app(reviews_handler) diff --git a/tests/test_federation_nested_local_pagination.py b/tests/test_federation_nested_local_pagination.py index 39b7d55e..55258750 100644 --- a/tests/test_federation_nested_local_pagination.py +++ b/tests/test_federation_nested_local_pagination.py @@ -46,6 +46,11 @@ class NLComment(NLReviewsBase, table=True): class NLReview(NLReviewsBase, table=True): __tablename__ = "nl_review" + __federation_keys__ = ["product_id"] + __pagination_orders__ = BatchPageConfig( + default_order="HIGHEST_RATING", + orders={"HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")])}, + ) id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -107,13 +112,7 @@ async def federation(): await _ensure_seed() reviews_handler = GraphQLHandler( base=NLReviewsBase, session_factory=_rev_sf, - auto_query_config=AutoQueryConfig( - batch_keys={"NLReview": ["product_id"]}, - batch_pages={"NLReview": {"product_id": BatchPageConfig( - default_order="HIGHEST_RATING", - orders={"HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")])}, - )}}, - ), + auto_query_config=AutoQueryConfig(), service_name="reviews", enable_pagination=True, # ← 内层本地分页开关 ) diff --git a/tests/test_federation_order_direction.py b/tests/test_federation_order_direction.py index 3640d470..bfd732e0 100644 --- a/tests/test_federation_order_direction.py +++ b/tests/test_federation_order_direction.py @@ -224,6 +224,11 @@ class _NullsBase(SQLModel): class _NullsItem(_NullsBase, table=True): __tablename__ = "nx14_nulls_item" + __federation_keys__ = ["group_id"] + __pagination_orders__ = BatchPageConfig( + default_order="RATING", + orders={"RATING": PageOrder([OrderTerm("rating", "desc", "last")])}, + ) id: int | None = Field(default=None, primary_key=True) group_id: int rating: int | None = None @@ -246,10 +251,6 @@ class _NullsItem(_NullsBase, table=True): base=_NullsBase, session_factory=sf, auto_query_config=AutoQueryConfig( generate_by_id=False, generate_by_filter=False, - batch_pages={"_NullsItem": {"group_id": BatchPageConfig( - default_order="RATING", - orders={"RATING": PageOrder([OrderTerm("rating", "desc", "last")])}, - )}}, ), service_name="member", ) @@ -299,6 +300,20 @@ class ODReviewsBase(SQLModel): class ODReview(ODReviewsBase, table=True): __tablename__ = "nx14_od_review" + __federation_keys__ = ["product_id"] + __pagination_orders__ = BatchPageConfig( + default_order="HIGHEST_RATING", + orders={ + "HIGHEST_RATING": PageOrder( + [OrderTerm("rating", "desc")], + description="Highest rating first", + ), + "NEWEST": PageOrder( + [OrderTerm("created_at", "desc")], + description="Newest first", + ), + }, + ) id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -371,26 +386,7 @@ async def od_federation(): await _od_ensure_seed() reviews_handler = GraphQLHandler( base=ODReviewsBase, session_factory=_rev_sf, - auto_query_config=AutoQueryConfig( - batch_keys={"ODReview": ["product_id"]}, - batch_pages={ - "ODReview": { - "product_id": BatchPageConfig( - default_order="HIGHEST_RATING", - orders={ - "HIGHEST_RATING": PageOrder( - [OrderTerm("rating", "desc")], - description="Highest rating first", - ), - "NEWEST": PageOrder( - [OrderTerm("created_at", "desc")], - description="Newest first", - ), - }, - ) - } - }, - ), + auto_query_config=AutoQueryConfig(), service_name="reviews", ) reviews_app = build_federable_app(reviews_handler) diff --git a/tests/test_federation_page_config.py b/tests/test_federation_page_config.py index 1273bd9a..c039798f 100644 --- a/tests/test_federation_page_config.py +++ b/tests/test_federation_page_config.py @@ -68,6 +68,15 @@ def _page_config(*terms: OrderTerm) -> BatchPageConfig: ) +@pytest.fixture(autouse=True) +def _reset_federation_dunders(): + """每个测试后重置模块级 entity 的 federation dunder,防跨测试污染。""" + yield + for kls in (PageConfigItem, PageConfigJson, PageOrderItem): + kls.__federation_keys__ = [] + kls.__pagination_orders__ = None + + @pytest.mark.parametrize( ("config", "message"), [ @@ -111,30 +120,28 @@ def _page_config(*terms: OrderTerm) -> BatchPageConfig: ], ) def test_invalid_page_order_config_rejected(config, message): + PageConfigItem.__federation_keys__ = ["product_id"] + PageConfigItem.__pagination_orders__ = config with pytest.raises((TypeError, ValueError), match=message): add_standard_queries( [PageConfigItem], AutoQueryConfig( generate_by_id=False, generate_by_filter=False, - batch_pages={"PageConfigItem": {"product_id": config}}, ), _unused_session, ) def test_json_order_field_rejected(): + PageConfigJson.__federation_keys__ = ["group_id"] + PageConfigJson.__pagination_orders__ = _page_config(OrderTerm("payload")) with pytest.raises(ValueError, match="unsupported column type"): add_standard_queries( [PageConfigJson], AutoQueryConfig( generate_by_id=False, generate_by_filter=False, - batch_pages={ - "PageConfigJson": { - "group_id": _page_config(OrderTerm("payload")) - } - }, ), _unused_session, ) @@ -146,6 +153,7 @@ class FullOnlyBase(SQLModel): class FullOnly(FullOnlyBase, table=True): __tablename__ = "fed_page_config_full_only" + __federation_keys__ = ["group_id"] id: int | None = Field(default=None, primary_key=True) group_id: int @@ -154,7 +162,6 @@ class FullOnly(FullOnlyBase, table=True): AutoQueryConfig( generate_by_id=False, generate_by_filter=False, - batch_keys={"FullOnly": ["group_id"]}, ), _unused_session, ) @@ -163,26 +170,25 @@ class FullOnly(FullOnlyBase, table=True): def test_multi_key_schema_and_er_capabilities_are_unique_and_semantic(): + PageConfigItem.__federation_keys__ = ["product_id", "category"] + # specs/020: single entity-level profile — product_id AND category (both in + # __federation_keys__) share it; each gets its own page_by__in + a + # per-field order enum, all backed by this one sort. + PageConfigItem.__pagination_orders__ = BatchPageConfig( + default_order="HIGHEST", + orders={ + "HIGHEST": PageOrder( + [OrderTerm("rating", "desc", "last")], + description="Highest rating first", + ) + }, + ) handler = GraphQLHandler( base=PageConfigBase, session_factory=_unused_session, auto_query_config=AutoQueryConfig( generate_by_id=False, generate_by_filter=False, - batch_pages={ - "PageConfigItem": { - "product_id": BatchPageConfig( - default_order="HIGHEST", - orders={ - "HIGHEST": PageOrder( - [OrderTerm("rating", "desc", "last")], - description="Highest rating first", - ) - }, - ), - "category": _page_config(OrderTerm("category")), - } - }, ), service_name="member", ) @@ -269,24 +275,21 @@ async def test_desc_nulls_last_and_pk_tie_breaker_are_stable(): ) await session.commit() + PageOrderItem.__federation_keys__ = ["product_id"] + PageOrderItem.__pagination_orders__ = BatchPageConfig( + default_order="HIGHEST", + orders={ + "HIGHEST": PageOrder( + [OrderTerm("rating", "desc", "last")] + ) + }, + ) handler = GraphQLHandler( base=PageOrderBase, session_factory=session_factory, auto_query_config=AutoQueryConfig( generate_by_id=False, generate_by_filter=False, - batch_pages={ - "PageOrderItem": { - "product_id": BatchPageConfig( - default_order="HIGHEST", - orders={ - "HIGHEST": PageOrder( - [OrderTerm("rating", "desc", "last")] - ) - }, - ) - } - }, ), service_name="member", ) diff --git a/tests/test_federation_pagination_e2e.py b/tests/test_federation_pagination_e2e.py index e975fc47..067d2e2c 100644 --- a/tests/test_federation_pagination_e2e.py +++ b/tests/test_federation_pagination_e2e.py @@ -43,6 +43,14 @@ class EPReviewsBase(SQLModel): class EPReview(EPReviewsBase, table=True): __tablename__ = "fed_pag_e2e_review" + __federation_keys__ = ["product_id"] + __pagination_orders__ = BatchPageConfig( + default_order="HIGHEST_RATING", + orders={ + "LOWEST_RATING": PageOrder([OrderTerm("rating", "asc")]), + "HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")]), + }, + ) id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -132,24 +140,7 @@ async def federation(): await _ensure_seed() reviews_handler = GraphQLHandler( base=EPReviewsBase, session_factory=_rev_sf, - auto_query_config=AutoQueryConfig( - batch_keys={"EPReview": ["product_id"]}, - batch_pages={ - "EPReview": { - "product_id": BatchPageConfig( - default_order="HIGHEST_RATING", - orders={ - "LOWEST_RATING": PageOrder( - [OrderTerm("rating", "asc")] - ), - "HIGHEST_RATING": PageOrder( - [OrderTerm("rating", "desc")] - ), - }, - ) - } - }, - ), + auto_query_config=AutoQueryConfig(), service_name="reviews", ) reviews_app = build_federable_app(reviews_handler) @@ -405,24 +396,7 @@ async def federation_paginated(): await _ensure_seed() reviews_handler = GraphQLHandler( base=EPReviewsBase, session_factory=_rev_sf, - auto_query_config=AutoQueryConfig( - batch_keys={"EPReview": ["product_id"]}, - batch_pages={ - "EPReview": { - "product_id": BatchPageConfig( - default_order="HIGHEST_RATING", - orders={ - "LOWEST_RATING": PageOrder( - [OrderTerm("rating", "asc")] - ), - "HIGHEST_RATING": PageOrder( - [OrderTerm("rating", "desc")] - ), - }, - ) - } - }, - ), + auto_query_config=AutoQueryConfig(), service_name="reviews", ) reviews_app = build_federable_app(reviews_handler) diff --git a/tests/test_federation_pagination_e2e_edges.py b/tests/test_federation_pagination_e2e_edges.py index 28b5ad9a..9e2bdf96 100644 --- a/tests/test_federation_pagination_e2e_edges.py +++ b/tests/test_federation_pagination_e2e_edges.py @@ -56,6 +56,14 @@ class EdgeMemberBase(SQLModel): # 复用于 default_order 场景(int join key)。 class EdgeReview(EdgeMemberBase, table=True): __tablename__ = "fed_pag_edge_review" + __federation_keys__ = ["product_id"] + __pagination_orders__ = BatchPageConfig( + default_order="LOWEST_RATING", + orders={ + "LOWEST_RATING": PageOrder([OrderTerm("rating", "asc")]), + "HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")]), + }, + ) id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -65,6 +73,11 @@ class EdgeReview(EdgeMemberBase, table=True): # UUID join key 场景:account_id 是 UUID 列,作为 page key。 class EdgeSession(EdgeMemberBase, table=True): __tablename__ = "fed_pag_edge_session" + __federation_keys__ = ["account_id"] + __pagination_orders__ = BatchPageConfig( + default_order="NEWEST", + orders={"NEWEST": PageOrder([OrderTerm("started_at", "desc")])}, + ) id: int | None = Field(default=None, primary_key=True) account_id: _uuid.UUID title: str @@ -163,31 +176,7 @@ async def federation(): await _ensure_seed() member_handler = GraphQLHandler( base=EdgeMemberBase, session_factory=_mem_sf, - auto_query_config=AutoQueryConfig( - batch_keys={ - "EdgeReview": ["product_id"], - "EdgeSession": ["account_id"], - }, - batch_pages={ - "EdgeReview": { - "product_id": BatchPageConfig( - default_order="LOWEST_RATING", - orders={ - "LOWEST_RATING": PageOrder([OrderTerm("rating", "asc")]), - "HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")]), - }, - ) - }, - "EdgeSession": { - "account_id": BatchPageConfig( - default_order="NEWEST", - orders={ - "NEWEST": PageOrder([OrderTerm("started_at", "desc")]), - }, - ) - }, - }, - ), + auto_query_config=AutoQueryConfig(), service_name="member", ) member_app = build_federable_app(member_handler) diff --git a/tests/test_federation_pagination_render.py b/tests/test_federation_pagination_render.py index 2102ea3d..7c565c5d 100644 --- a/tests/test_federation_pagination_render.py +++ b/tests/test_federation_pagination_render.py @@ -41,6 +41,11 @@ class RReviewBase(SQLModel): class RReview(RReviewBase, table=True): __tablename__ = "fed_pag_render_review" + __federation_keys__ = ["product_id"] + __pagination_orders__ = BatchPageConfig( + default_order="HIGHEST_RATING", + orders={"HIGHEST_RATING": PageOrder([OrderTerm("rating", "desc")])}, + ) id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -107,21 +112,7 @@ async def catalog(): await _ensure_seed() reviews_handler = GraphQLHandler( base=RReviewBase, session_factory=_rev_sf, - auto_query_config=AutoQueryConfig( - batch_keys={"RReview": ["product_id"]}, - batch_pages={ - "RReview": { - "product_id": BatchPageConfig( - default_order="HIGHEST_RATING", - orders={ - "HIGHEST_RATING": PageOrder( - [OrderTerm("rating", "desc")] - ) - }, - ) - } - }, - ), + auto_query_config=AutoQueryConfig(), service_name="reviews", ) reviews_app = build_federable_app(reviews_handler) diff --git a/tests/test_federation_pagination_transitive.py b/tests/test_federation_pagination_transitive.py new file mode 100644 index 00000000..d6eb28bb --- /dev/null +++ b/tests/test_federation_pagination_transitive.py @@ -0,0 +1,163 @@ +"""三层联邦分页穿透:A→B→C,B/C enable_pagination=False(默认),验证联邦分页仍工作。 + +验证 specs/020 核心:联邦分页(page_by_)由 __federation_keys__ + __pagination_orders__ +驱动,与 enable_pagination(本地关系分页开关)正交。即使 B/C 没开 enable_pagination, +A 的分页查询穿透到 C 仍正常 —— 因为联邦分页根独立于本地分页开关。 +""" +import httpx +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlmodel import Field, SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession +from starlette.applications import Starlette +from starlette.routing import Mount + +from nexusx import AutoQueryConfig, BatchPageConfig, GraphQLHandler, OrderTerm, PageOrder +from nexusx.federation import RemoteRelationship, RemoteService +from nexusx.federation.http import GraphQLTransport +from nexusx.federation.introspect import build_federable_app + +users = RemoteService("users", url="http://test/users") +reviews = RemoteService("reviews", url="http://test/reviews") + + +class _UsersBase(SQLModel): + pass + + +class _ReviewsBase(SQLModel): + pass + + +class _CatalogBase(SQLModel): + pass + + +# C member(叶子)—— enable_pagination 不开,但有联邦分页能力 +class CEntity(_UsersBase, table=True): + __tablename__ = "trans_pag_c" + __federation_keys__ = ["b_id"] + __pagination_orders__ = BatchPageConfig( + default_order="TOP", + orders={"TOP": PageOrder([OrderTerm("score", "desc")])}, + ) + id: int | None = Field(default=None, primary_key=True) + b_id: int + name: str + score: int + + +# B member + mounter —— enable_pagination 不开,联邦 A + 挂 C(分页) +class BEntity(_ReviewsBase, table=True): + __tablename__ = "trans_pag_b" + __federation_keys__ = ["a_id"] + __pagination_orders__ = BatchPageConfig( + default_order="TOP", + orders={"TOP": PageOrder([OrderTerm("score", "desc")])}, + ) + id: int | None = Field(default=None, primary_key=True) + a_id: int + name: str + score: int + __relationships__ = [ + RemoteRelationship( + fk="id", target=list[users.CEntity], + name="cs", join_remote="b_id", + pagination=True, + ), + ] + + +# A mounter —— 联邦 B(分页) +class AEntity(_CatalogBase, table=True): + __tablename__ = "trans_pag_a" + id: int | None = Field(default=None, primary_key=True) + name: str + __relationships__ = [ + RemoteRelationship( + fk="id", target=list[reviews.BEntity], + name="bs", join_remote="a_id", + pagination=True, + ), + ] + + +@pytest.fixture(scope="module") +async def _engines(): + eng = { + "users": create_async_engine("sqlite+aiosqlite:///:memory:"), + "reviews": create_async_engine("sqlite+aiosqlite:///:memory:"), + "catalog": create_async_engine("sqlite+aiosqlite:///:memory:"), + } + for e in eng.values(): + async with e.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + yield eng + for e in eng.values(): + await e.dispose() + + +@pytest.mark.asyncio +async def test_transitive_pagination_without_enable_pagination(_engines): + # seed: A1 → [B1(5), B2(3)];B1 → [C1(9), C2(7)];B2 → [C3(5)] + users_sf = async_sessionmaker(_engines["users"], class_=AsyncSession, expire_on_commit=False) + async with users_sf() as s: + s.add(CEntity(id=1, b_id=1, name="C1", score=9)) + s.add(CEntity(id=2, b_id=1, name="C2", score=7)) + s.add(CEntity(id=3, b_id=2, name="C3", score=5)) + await s.commit() + + reviews_sf = async_sessionmaker(_engines["reviews"], class_=AsyncSession, expire_on_commit=False) + async with reviews_sf() as s: + s.add(BEntity(id=1, a_id=1, name="B1", score=5)) + s.add(BEntity(id=2, a_id=1, name="B2", score=3)) + await s.commit() + + catalog_sf = async_sessionmaker(_engines["catalog"], class_=AsyncSession, expire_on_commit=False) + async with catalog_sf() as s: + s.add(AEntity(id=1, name="A1")) + await s.commit() + + # handlers —— 注意:都不传 enable_pagination(默认 False) + users_h = GraphQLHandler( + base=_UsersBase, session_factory=users_sf, + auto_query_config=AutoQueryConfig(), service_name="users", + ) + reviews_h = GraphQLHandler( + base=_ReviewsBase, session_factory=reviews_sf, + auto_query_config=AutoQueryConfig(), service_name="reviews", + expose_mounted_endpoints=True, # 让 catalog transitive 发现 users + ) + catalog_h = GraphQLHandler( + base=_CatalogBase, session_factory=catalog_sf, + auto_query_config=AutoQueryConfig(), service_name="catalog", + ) + + composite = Starlette(routes=[ + Mount("/users", app=build_federable_app(users_h)), + Mount("/reviews", app=build_federable_app(reviews_h)), + ]) + client = httpx.AsyncClient(transport=httpx.ASGITransport(app=composite), base_url="http://test") + transport = GraphQLTransport(client=client) + + # reviews mounts users; catalog mounts reviews (transitive → users) + await reviews_h.er.initialize(transport=transport) + await catalog_h.er.initialize(transport=transport) + + try: + res = await catalog_h.execute( + "{ AEntity { by_id(id: 1) { " + "bs(limit: 1, order: TOP) { items { name score " + "cs(limit: 1, order: TOP) { items { name score } } } } } } }" + ) + # 即使 B/C enable_pagination=False,联邦分页穿透应正常 + assert not res.get("errors"), res + a = res["data"]["AEntity"]["by_id"] + bs = a["bs"]["items"] + assert len(bs) == 1 + assert bs[0]["name"] == "B1" # score=5 > B2 score=3 → top + cs = bs[0]["cs"]["items"] + assert len(cs) == 1 + assert cs[0]["name"] == "C1" # score=9 > C2 score=7 → top + finally: + await client.aclose() diff --git a/tests/test_federation_remote_loader.py b/tests/test_federation_remote_loader.py index 048ef49e..4304a165 100644 --- a/tests/test_federation_remote_loader.py +++ b/tests/test_federation_remote_loader.py @@ -197,10 +197,11 @@ def test_batch_roots_introspect_arg_contract(): class T(SQLModel, table=True): __tablename__ = "fed_p1a_argroots" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int - add_standard_queries([T], AutoQueryConfig(batch_keys={"T": ["product_id"]}), lambda: None) + add_standard_queries([T], AutoQueryConfig(), lambda: None) roots = {r.name: r for r in _batch_roots(T)} br = roots["by_product_id_in"] assert br.arg_name == "product_id_list" diff --git a/tests/test_federation_resolver_context.py b/tests/test_federation_resolver_context.py index 8f4e676f..01cfb909 100644 --- a/tests/test_federation_resolver_context.py +++ b/tests/test_federation_resolver_context.py @@ -52,6 +52,7 @@ class _ReviewsBase(SQLModel): class CtxReview(_ReviewsBase, table=True): __tablename__ = "ctx_fed_review" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -134,7 +135,7 @@ async def _federated(): reviews_h = GraphQLHandler( base=_ReviewsBase, session_factory=sf["ctxreviews"], - auto_query_config=AutoQueryConfig(batch_keys={"CtxReview": ["product_id"]}), + auto_query_config=AutoQueryConfig(), service_name="ctxreviews", ) catalog_h = GraphQLHandler( diff --git a/tests/test_federation_resolver_deep_chain.py b/tests/test_federation_resolver_deep_chain.py index c2c5bd41..97ed0143 100644 --- a/tests/test_federation_resolver_deep_chain.py +++ b/tests/test_federation_resolver_deep_chain.py @@ -47,6 +47,7 @@ class RCUserConfig(_UsersBase, table=True): class RCUser(_UsersBase, table=True): __tablename__ = "rc_resolver_user" + __federation_keys__ = ["id"] id: int | None = Field(default=None, primary_key=True) name: str config: RCUserConfig | None = Relationship(sa_relationship_kwargs={"uselist": False}) @@ -73,6 +74,7 @@ class RCComment(_ReviewsBase, table=True): class RCReview(_ReviewsBase, table=True): __tablename__ = "rc_resolver_review" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -171,12 +173,12 @@ def sf(k): users_h = GraphQLHandler( base=_UsersBase, session_factory=sf("rcusers"), - auto_query_config=AutoQueryConfig(batch_keys={"RCUser": ["id"]}), + auto_query_config=AutoQueryConfig(), service_name="rcusers", ) reviews_h = GraphQLHandler( base=_ReviewsBase, session_factory=sf("rcreviews"), - auto_query_config=AutoQueryConfig(batch_keys={"RCReview": ["product_id"]}), + auto_query_config=AutoQueryConfig(), service_name="rcreviews", expose_mounted_endpoints=True, ) catalog_h = GraphQLHandler( diff --git a/tests/test_federation_transitive.py b/tests/test_federation_transitive.py index 3ed4630a..cbb88292 100644 --- a/tests/test_federation_transitive.py +++ b/tests/test_federation_transitive.py @@ -42,12 +42,14 @@ class _CatalogBase(SQLModel): class TransUser(_UsersBase, table=True): __tablename__ = "fed_trans_user" + __federation_keys__ = ["id"] id: int | None = Field(default=None, primary_key=True) name: str class TransReview(_ReviewsBase, table=True): __tablename__ = "fed_trans_review" + __federation_keys__ = ["product_id", "author_id"] id: int | None = Field(default=None, primary_key=True) product_id: int author_id: int @@ -112,12 +114,12 @@ async def test_transitive_discovery_reaches_users_through_reviews(_engines): users_h = GraphQLHandler( base=_UsersBase, session_factory=users_sf, - auto_query_config=AutoQueryConfig(batch_keys={"TransUser": ["id"]}), + auto_query_config=AutoQueryConfig(), service_name="users", ) reviews_h = GraphQLHandler( base=_ReviewsBase, session_factory=reviews_sf, - auto_query_config=AutoQueryConfig(batch_keys={"TransReview": ["product_id", "author_id"]}), + auto_query_config=AutoQueryConfig(), service_name="reviews", # Opt in so catalog can discover users' endpoint transitively through # reviews' introspection payload (suppressed by default to avoid leaking diff --git a/tests/test_federation_voyager.py b/tests/test_federation_voyager.py index ac4ae55b..19b8376a 100644 --- a/tests/test_federation_voyager.py +++ b/tests/test_federation_voyager.py @@ -137,6 +137,7 @@ class _ColorRevBase(SQLModel): class ColorReview(_ColorRevBase, table=True): __tablename__ = "voyager_color_review" + __federation_keys__ = ["product_id"] id: int | None = Field(default=None, primary_key=True) product_id: int title: str @@ -179,7 +180,7 @@ async def test_declared_remote_service_color_renders(): rev_h = GraphQLHandler( base=_ColorRevBase, session_factory=sf["rev"], - auto_query_config=AutoQueryConfig(batch_keys={"ColorReview": ["product_id"]}), + auto_query_config=AutoQueryConfig(), service_name="creviews", ) cat_h = GraphQLHandler( diff --git a/tests/test_local_pagination_order.py b/tests/test_local_pagination_order.py index 5d8b4f0a..5d0bdaf6 100644 --- a/tests/test_local_pagination_order.py +++ b/tests/test_local_pagination_order.py @@ -23,6 +23,14 @@ class LOBase(SQLModel): class LOComment(LOBase, table=True): __tablename__ = "lo_comment" + # specs/020: Comment's own sort — read when LOReview.comments is paginated. + __pagination_orders__ = BatchPageConfig( + default_order="NEWEST", + orders={ + "NEWEST": PageOrder([OrderTerm("created_at", "desc")]), + "MOST_LIKED": PageOrder([OrderTerm("likes", "desc", nulls="last")]), + }, + ) id: int | None = Field(default=None, primary_key=True) text: str likes: int | None = Field(default=None) # nullable → MOST_LIKED 用 nulls @@ -39,17 +47,7 @@ class LOReview(LOBase, table=True): back_populates="review", sa_relationship_kwargs={"order_by": "LOComment.id"}, ) - __pagination_orders__ = { - "comments": BatchPageConfig( - default_order="NEWEST", - orders={ - "NEWEST": PageOrder([OrderTerm("created_at", "desc")]), - "MOST_LIKED": PageOrder( - [OrderTerm("likes", "desc", nulls="last")] - ), - }, - ), - } + # specs/020: comments order profile lives on LOComment (the sorted object). _engine = create_async_engine("sqlite+aiosqlite:///:memory:") diff --git a/tests/test_local_pagination_order_render.py b/tests/test_local_pagination_order_render.py index f052ef20..b764c8b6 100644 --- a/tests/test_local_pagination_order_render.py +++ b/tests/test_local_pagination_order_render.py @@ -23,6 +23,13 @@ class LPOBase(SQLModel): class LPOComment(LPOBase, table=True): __tablename__ = "lpo_comment" + __pagination_orders__ = BatchPageConfig( + default_order="NEWEST", + orders={ + "NEWEST": PageOrder([OrderTerm("created_at", "desc")]), + "MOST_LIKED": PageOrder([OrderTerm("likes", "desc", nulls="last")]), + }, + ) id: int | None = Field(default=None, primary_key=True) text: str likes: int = 0 @@ -39,16 +46,7 @@ class LPOReview(LPOBase, table=True): back_populates="review", sa_relationship_kwargs={"order_by": "LPOComment.id"}, ) - # 类级 order profile 声明(specs/015) - __pagination_orders__ = { - "comments": BatchPageConfig( - default_order="NEWEST", - orders={ - "NEWEST": PageOrder([OrderTerm("created_at", "desc")]), - "MOST_LIKED": PageOrder([OrderTerm("likes", "desc", nulls="last")]), - }, - ), - } + # specs/020: comments order profile lives on LPOComment (the sorted object). LPO_ENTITIES = [LPOReview, LPOComment] diff --git a/tests/test_paged_provider.py b/tests/test_paged_provider.py index 8337b8a9..03a70f7f 100644 --- a/tests/test_paged_provider.py +++ b/tests/test_paged_provider.py @@ -17,7 +17,6 @@ from nexusx.execution.query_executor import QueryExecutor from nexusx.loader.pagination import Paged from nexusx.query_parser import FieldSelection -from nexusx.resolver import Resolver def _provider(): @@ -91,32 +90,6 @@ def test_provider_returns_paged_instance(): assert isinstance(p, Paged) -def test_explicit_zero_offset_overrides_nonzero_default(): - """Caller offset=0 is an explicit first-page override, not an omission.""" - resolver = Resolver(context={"offset": 0}) - caller = resolver._extract_page_params("reviews") - - assert caller is not None - merged = Resolver._merge_paged( - Paged(limit=5, offset=4), - caller, - ) - assert merged.offset == 0 - - -def test_omitted_offset_preserves_nonzero_default(): - """A caller that only overrides limit must not reset the field offset.""" - resolver = Resolver(context={"limit": 1}) - caller = resolver._extract_page_params("reviews") - - merged = Resolver._merge_paged( - Paged(limit=5, offset=4), - caller, - ) - assert merged.limit == 1 - assert merged.offset == 4 - - def test_multi_paged_fields_each_get_own_effective(): """同层多个 paged 字段,provider 按 field_name 各算 effective,不串。