GraphQL mode auto-generates a full query language. But many APIs are simpler — they just need well-shaped REST responses with related data loaded efficiently.
Core API gives you the same DataLoader batch loading and N+1 prevention, but as building blocks you control: define your DTOs, declare which fields need related data, and let the Resolver assemble the tree.
DefineSubset picks fields from your SQLModel entities and generates Pydantic models. Relationship fields declared in the class body are auto-loaded by name:
from nexusx import DefineSubset
class UserDTO(DefineSubset):
__subset__ = (User, ("id", "name"))
class TaskDTO(DefineSubset):
__subset__ = (Task, ("id", "title", "owner_id"))
owner: UserDTO | None = None # Name matches Task.owner → auto-loaded
class SprintDTO(DefineSubset):
__subset__ = (Sprint, ("id", "name"))
tasks: list[TaskDTO] = [] # Name matches Sprint.tasks → auto-loaded- Picks fields from the source entity — only the ones you list appear in the output
- Hides FK fields automatically —
owner_idwon't appear in the JSON response, but remains accessible internally inresolve_* - Relationship fields go in the class body, not in
__subset__— their types must be DTO types
!!! warning
Relationship field types must be DTO types (DefineSubset or BaseModel subclasses). Using SQLModel entity types directly will raise a TypeError.
# Wrong — SQLModel entity type
class TaskDTO(DefineSubset):
__subset__ = (Task, ("id", "title"))
owner: User | None = None # TypeError!
# Correct — DTO type
class TaskDTO(DefineSubset):
__subset__ = (Task, ("id", "title"))
owner: UserDTO | None = None # OKAt application startup — run once:
from nexusx import ErManager
er = ErManager(base=SQLModel, session_factory=async_session)
Resolver = er.create_resolver()ErManagerdiscovers all SQLModel entities and their relationshipscreate_resolver()returns a Resolver class bound to the entity graph
!!! warning
base and entities parameters are mutually exclusive — you can't pass both.
from fastapi import FastAPI
from sqlmodel import select
app = FastAPI()
@app.get("/api/sprints")
async def get_sprints():
async with async_session() as session:
sprints = (await session.exec(select(Sprint))).all()
dtos = [SprintDTO(id=s.id, name=s.name) for s in sprints]
return await Resolver().resolve(dtos)The Resolver traverses the DTO tree and fills in all relationship fields. You write the query, build the DTOs, and call resolve().
When the Resolver encounters SprintDTO, it resolves the tree layer by layer:
SprintDTO(id=1, name="Sprint 1")
→ Auto-load: tasks → [TaskDTO(...), TaskDTO(...)]
→ Each TaskDTO: Auto-load: owner → UserDTO(...)
→ Result: complete nested response
Each relationship executes only one DataLoader query, regardless of how many Sprints or Tasks exist.
The Resolver auto-loads a field when all four conditions are met:
- The field has no corresponding
resolve_*method - The field is an extra field (not in
__subset__) - The field name matches a registered ORM/custom relationship
- The field type is a BaseModel DTO compatible with the relationship target
When any condition fails, you can use resolve_* to load the field manually — see Core API Advanced.
DefineSubsetgenerates DTOs from SQLModel entities — pick fields, hide FKs, declare relationship fields- Relationship fields are auto-loaded when the field name matches a registered relationship
ErManagerdiscovers entities;Resolvertraverses and assembles the DTO tree- Relationship field types must be DTO types, not SQLModel entities
- Core API Advanced —
resolve_*/post_*/ cross-layer data flow - Custom Relationships — Non-ORM relationship declarations
- ER Diagram — How ErManager discovers relationships