Skip to content

Enhancement: auto-resolve DTO-declared relationship fields when model uses lazy="noload" #108

Description

@allmonday

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:

  1. SELECT the association + target rows (single batched JOIN)
  2. Group by parent_id
  3. setattr onto the model instance to override noload
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions