Summary
When a SQLModel entity uses lazy="noload" on a relationship (common for performance), but its corresponding DTO declares the field as a list of child DTOs, Resolver().resolve(dto) does not populate that field — it stays empty [] forever, even when the underlying data exists. Today every read-side service method must hand-write the lookup.
Environment
- nexusx: 3.6.2
- Python: 3.13
- Pydantic: 2.13.4
- SQLModel: 0.0.39
Scenario
Parent/Child models with an association table in between (typical M:N through-association-object pattern):
class Parent(SQLModel, table=True):
id: UUID
children: List["Association"] = Relationship(
back_populates="parent",
sa_relationship_kwargs={"lazy": "noload"}, # performance opt-in
)
# Note: no direct `places: List[Place]` relationship,
# because through-association-object M:N config in SQLModel is brittle.
class Association(SQLModel, table=True):
id: UUID
parent_id: UUID # FK
target_id: UUID # FK
order: int
class Target(SQLModel, table=True):
id: UUID
name: str
class ParentDTO(DefineSubset):
__subset__ = SubsetConfig(kls=Parent, fields=["id"])
targets: List[TargetDTO] = [] # ← declared on DTO, not on model
Expected behavior
Resolver().resolve(parent_dto) recognizes that ParentDTO.targets is a declared field not present on the Parent model, looks for an association table linking Parent → Target, batch-loads via a single JOIN, and populates dto.targets.
Or, failing auto-detection, provide an explicit config knob:
class ParentDTO(DefineSubset):
__subset__ = SubsetConfig(kls=Parent, fields=["id"])
targets: List[TargetDTO] = []
__resolver_hints__ = {
"targets": {
"through": "Association", # association table
"source_fk": "parent_id",
"target_fk": "target_id",
"order_by": "Association.order",
}
}
Actual behavior
Resolver().resolve(dto) only forwards fields already on the model instance. With lazy="noload", the relationship isn't loaded; the DTO field stays [] regardless of underlying data.
Current workaround
Every read entry-point (list_parents / get_parent / get_owner_of_parent) must manually:
- SELECT the association + target rows (single batched JOIN)
- Group by parent_id
- setattr onto the model instance to override
noload
- THEN call
model_validate to produce the DTO
async def get_parent(id: UUID) -> Parent:
async with async_session() as session:
parent = await session.get(Parent, id)
# Manual batch-load through the association table
rows = (await session.exec(
select(Association.parent_id, Target)
.join(Target, Target.id == Association.target_id)
.where(Association.parent_id == id)
.order_by(Association.order)
)).all()
targets_by_parent: dict[UUID, List[Target]] = {}
for pid, t in rows:
targets_by_parent.setdefault(pid, []).append(t)
parent.targets = targets_by_parent.get(id, []) # type: ignore[attr-defined]
# ↑ also bites Pydantic v2 setattr restriction if `targets` isn't a declared field on Parent
return parent
This pattern must be replicated in every service method that returns a Parent DTO, plus anywhere the parent is fetched transitively (e.g., when parent is nested under a higher-level aggregate). Easy to miss; invites stale-empty-list bugs in untested code paths.
Why this matters
lazy="noload" is a recommended SQLAlchemy pattern for write-heavy / list-heavy services — it prevents N+1 disasters. But the current Resolver design effectively forces users to choose between:
lazy="selectin" / "joined" (risk N+1 / over-fetching)
lazy="noload" + hand-written per-method resolution
Auto-resolution (or a config-driven loader) would make lazy="noload" a first-class option without per-method boilerplate.
Related friction
Even setting parent.targets = [...] on the model instance bites Pydantic v2's strict schema enforcement (raises ValueError: "Parent" object has no field "targets"), forcing the workaround to live entirely in service-layer DTO manipulation, not model-instance mutation. A Resolver-level solution would sidestep this.
Summary
When a SQLModel entity uses
lazy="noload"on a relationship (common for performance), but its corresponding DTO declares the field as a list of child DTOs,Resolver().resolve(dto)does not populate that field — it stays empty[]forever, even when the underlying data exists. Today every read-side service method must hand-write the lookup.Environment
Scenario
Parent/Child models with an association table in between (typical M:N through-association-object pattern):
Expected behavior
Resolver().resolve(parent_dto)recognizes thatParentDTO.targetsis a declared field not present on the Parent model, looks for an association table linking Parent → Target, batch-loads via a single JOIN, and populatesdto.targets.Or, failing auto-detection, provide an explicit config knob:
Actual behavior
Resolver().resolve(dto)only forwards fields already on the model instance. Withlazy="noload", the relationship isn't loaded; the DTO field stays[]regardless of underlying data.Current workaround
Every read entry-point (list_parents / get_parent / get_owner_of_parent) must manually:
noloadmodel_validateto produce the DTOThis pattern must be replicated in every service method that returns a Parent DTO, plus anywhere the parent is fetched transitively (e.g., when parent is nested under a higher-level aggregate). Easy to miss; invites stale-empty-list bugs in untested code paths.
Why this matters
lazy="noload"is a recommended SQLAlchemy pattern for write-heavy / list-heavy services — it prevents N+1 disasters. But the current Resolver design effectively forces users to choose between:lazy="selectin"/"joined"(risk N+1 / over-fetching)lazy="noload"+ hand-written per-method resolutionAuto-resolution (or a config-driven loader) would make
lazy="noload"a first-class option without per-method boilerplate.Related friction
Even setting
parent.targets = [...]on the model instance bites Pydantic v2's strict schema enforcement (raisesValueError: "Parent" object has no field "targets"), forcing the workaround to live entirely in service-layer DTO manipulation, not model-instance mutation. A Resolver-level solution would sidestep this.