Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
{"feature_directory":"specs/019-composable-er-manager"}
{
"feature_directory": "specs/020-federation-config-orthogonal"
}
20 changes: 7 additions & 13 deletions demo/core_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down
10 changes: 5 additions & 5 deletions demo/core_api/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])

Expand Down
14 changes: 2 additions & 12 deletions demo/core_api/models/planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
10 changes: 10 additions & 0 deletions demo/core_api/models/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
12 changes: 5 additions & 7 deletions demo/federation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<key>_in` entry roots | `AutoQueryConfig(batch_keys=...)` on each member |
| Member-owned pagination order | `AutoQueryConfig(batch_pages=...)` on reviews |
| `by_<key>_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)
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 29 additions & 37 deletions demo/federation/reviews_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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")])},
)


Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion demo/federation/users_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
)

Expand Down
92 changes: 57 additions & 35 deletions docs/advanced/federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<key>_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

Expand Down Expand Up @@ -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_<key>_in` root for **every** federation key (in addition to the plain
`by_<key>_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
Expand Down Expand Up @@ -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])
Expand All @@ -204,19 +207,38 @@ 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

| | β (gql) | γ (UseCase/Resolver) |
|---|---|---|
| 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_<key>_in` root; if the entity declares
`__pagination_orders__`, every federation key additionally yields
`page_by_<key>_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`
Expand Down
Loading