diff --git a/README.md b/README.md index 57ad583..339a0e0 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,25 @@ [![pypi](https://img.shields.io/pypi/v/nexusx.svg)](https://pypi.python.org/pypi/nexusx) [![PyPI Downloads](https://static.pepy.tech/badge/nexusx/month)](https://pepy.tech/projects/nexusx) -> **Model your business once — for humans and AI alike.** +> **Declare your SQLModel entities once — GraphQL, REST, MCP, CLI, and a +> TypeScript SDK all derive from that single model.** -nexusx is a next-generation business modeling tool with deep AI integration. -Model your business entities, relationships, and use cases once — GraphQL, -REST, MCP, CLI, and TS SDK all derive from that single model, sharing one -DataLoader-backed query graph (N+1-proof) and one set of typed DTOs -(`DefineSubset`). This is **semantic-level isomorphism** — not -transport-level wrapping. Data is a graph; tools are just its projections. +nexusx is a Python library for SQLModel applications. You declare entities + +relationships, `DefineSubset` DTOs, and use-case methods; nexusx derives every +delivery protocol from that one model — sharing one DataLoader-backed query +graph (N+1-proof) and the same typed DTOs everywhere. + +What it removes: in a typical FastAPI + SQLModel app you re-declare the same +data shape for each transport — response models, GraphQL types, MCP tool +schemas, CLI arguments. nexusx collapses those re-declarations into one. + +| You declare | nexusx generates | +|---|---| +| SQLModel entity + relationships | GraphQL schema + DataLoader batching (N+1-proof) + ER diagrams | +| `DefineSubset` DTO | Minimal-column queries + nested relationship loading + computed fields | +| `UseCaseService` method | REST route + GraphQL field + MCP operation + CLI command | +| A non-ORM async batch function | A relationship that joins the same loaders, DTOs, and ER diagrams | +| Entity `__federation_keys__` | Cross-service federation (auto-detected + batch-fetched) | **For Human** — write SQLModel entities + typed DTOs; get REST routes, GraphQL schema, CLI, and TS SDK without boilerplate. Change business logic once → all @@ -41,9 +52,81 @@ pip install "nexusx[cli]" # Typer CLI generation nexusx requires Python 3.10 or newer. +## Why nexusx + +A SQLModel application usually grows through the same stages: + +1. Define entities and relationships. +2. Write resolvers or joins to read nested data. +3. Create response DTOs that do not expose every database column. +4. Repeat the same business operation for REST, GraphQL, and AI tools. +5. Rebuild the relationship map again for documentation and service boundaries. + +nexusx keeps those stages connected: each artifact you declare feeds the next +one and every delivery protocol at once, instead of being re-declared per layer. +The result is less translation code between your database, application layer, +web API, and AI interface. + +```mermaid +flowchart LR + models["SQLModel entities"] + data_graph["Data graph
relationships + loaders"] + dto["Typed DTOs
DefineSubset + Resolver"] + usecase["Business use cases"] + + models --> data_graph --> dto --> usecase + data_graph --> data_gql["GraphQL"] + data_graph --> data_mcp["MCP"] + data_graph --> er["ER / Voyager"] + usecase --> rest["REST / OpenAPI"] + usecase --> operation_gql["GraphQL"] + usecase --> operation_mcp["MCP"] + usecase --> cli["CLI"] +``` + +The Quick start below works this way: two entity declarations become a GraphQL +schema, query roots, and a batched relationship loader — without a +hand-written GraphQL type or resolver. The same pattern extends to every layer, +from the data graph up to use cases and federation. + ## Quick start -Create `app.py` (with `nexusx[demo]` installed): +Install `nexusx[demo]` (see [Installation](#installation)) and create `app.py`. +The model is two entity declarations plus one handler — no GraphQL types, no +resolvers: + +```python +from sqlmodel import Field, Relationship, SQLModel + +from nexusx import AutoQueryConfig, GraphQLHandler + + +class BaseEntity(SQLModel): + pass + + +class Team(BaseEntity, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str + heroes: list["Hero"] = Relationship(back_populates="team") + + +class Hero(BaseEntity, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str + team_id: int | None = Field(default=None, foreign_key="team.id") + team: Team | None = Relationship(back_populates="heroes") + + +handler = GraphQLHandler( + base=BaseEntity, + session_factory=session_factory, # async SQLAlchemy sessions — full file below + auto_query_config=AutoQueryConfig(), +) +``` + +
+Complete app.py — SQLite engine, FastAPI wiring, seed data ```python from contextlib import asynccontextmanager @@ -128,6 +211,8 @@ async def graphql(request: GraphQLRequest): return await handler.execute(request.query) ``` +
+ Run it: ```bash @@ -196,50 +281,6 @@ The runnable source is at `--check` to watch the two-tool walkthrough execute against a seeded database. -## Why nexusx - -A SQLModel application usually grows through the same stages: - -1. Define entities and relationships. -2. Write resolvers or joins to read nested data. -3. Create response DTOs that do not expose every database column. -4. Repeat the same business operation for REST, GraphQL, and AI tools. -5. Rebuild the relationship map again for documentation and service boundaries. - -nexusx keeps those stages connected: each artifact you declare feeds the next -one and every delivery protocol at once, instead of being re-declared per layer. -The result is less translation code between your database, application layer, -web API, and AI interface. - -```mermaid -flowchart LR - models["SQLModel entities"] - data_graph["Data graph
relationships + loaders"] - dto["Typed DTOs
DefineSubset + Resolver"] - usecase["Business use cases"] - - models --> data_graph --> dto --> usecase - data_graph --> data_gql["GraphQL"] - data_graph --> data_mcp["MCP"] - data_graph --> er["ER / Voyager"] - usecase --> rest["REST / OpenAPI"] - usecase --> operation_gql["GraphQL"] - usecase --> operation_mcp["MCP"] - usecase --> cli["CLI"] -``` - -The Quick start already worked this way: two entity declarations became a -GraphQL schema, query roots, and a batched relationship loader without a -hand-written GraphQL type or resolver. The same pattern extends to every layer: - -| You declare | nexusx generates | -|---|---| -| SQLModel entity + relationships | GraphQL schema + DataLoader batching (N+1-proof) + ER diagrams | -| `DefineSubset` DTO | Minimal-column queries + nested relationship loading + computed fields | -| `UseCaseService` method | REST route + GraphQL field + MCP operation + CLI command | -| A non-ORM async batch function | A relationship that joins the same loaders, DTOs, and ER diagrams | -| Entity `__federation_keys__` | Cross-service federation (auto-detected + batch-fetched) | - ## Explore the data graph For a larger model, add more entities under the same `BaseEntity`: @@ -521,6 +562,11 @@ Applications can use either one or both. ## Three ideas behind nexusx +Model your business once — for humans and AI alike. Data is a graph; delivery +protocols are just its projections. The deliverables above are therefore not +wrappers around each other — this is **semantic-level isomorphism**, not +transport-level wrapping. Three design decisions follow from that: + ### Selection is a first-class concept A field selection is not limited to the GraphQL transport. It influences: diff --git a/src/nexusx/standard_queries.py b/src/nexusx/standard_queries.py index e95599a..6650e7e 100644 --- a/src/nexusx/standard_queries.py +++ b/src/nexusx/standard_queries.py @@ -380,7 +380,13 @@ async def page_by_field_in(cls, **kwargs: Any) -> Any: limit=kwargs.get("limit"), offset=kwargs.get("offset", 0), ) - raw_order = kwargs["order"] + # ``order`` is optional per the SDL (default_order lives in the + # capability); SDL-direct callers (e.g. MCP agents) may omit it, and + # GraphQL ``null`` lands here as None too. Guard on ``is None`` — + # not truthiness — so invalid strings still reach the profile check. + raw_order = kwargs.get("order") + if raw_order is None: + raw_order = page_config.default_order order_name = raw_order.value if isinstance(raw_order, Enum) else raw_order if order_name not in resolved_orders: msg = ( @@ -509,7 +515,7 @@ async def page_by_field_in(cls, **kwargs: Any) -> Any: ), inspect.Parameter( "order", inspect.Parameter.POSITIONAL_OR_KEYWORD, - annotation=order_enum, + default=page_config.default_order, annotation=order_enum, ), inspect.Parameter( "direction", inspect.Parameter.POSITIONAL_OR_KEYWORD, diff --git a/tests/test_selection_validation.py b/tests/test_selection_validation.py index 36b1206..1c95136 100644 --- a/tests/test_selection_validation.py +++ b/tests/test_selection_validation.py @@ -196,6 +196,25 @@ async def test_pagination_root_package_selection_is_valid(): assert packages[0]["items"][0]["name"] == "Avengers" +@pytest.mark.asyncio +async def test_pagination_root_omitted_order_uses_default(): + """Omitting ``order`` — legal per the SDL, which shows it without ``!`` — + falls back to the member's default_order instead of raising KeyError. + + This is the SDL-direct caller path (e.g. an MCP agent writing queries + from get_schema output): every federation e2e test goes through the + mounter, which always sends ``order`` explicitly and masked this gap. + """ + result = await handler.execute( + "{ SelectionValTeam { page_by_id_in(id_list: [1]) { " + "id items { name } pagination { has_more } } } }" + ) + + assert not result.get("errors"), result + packages = result["data"]["SelectionValTeam"]["page_by_id_in"] + assert packages[0]["items"][0]["name"] == "Avengers" + + @pytest.mark.asyncio async def test_pagination_root_unknown_key_names_package_type(): """An unknown key on a pagination root errors naming the PagePackage