Skip to content

to_schema: nested structs fail to decode when the outer struct's field names are unchanged by rename #679

Description

@cofin

Summary

to_schema infers a msgspec struct's rename convention from the outer struct's own field names, but then applies that single verdict recursively to nested data. When every top-level field name is unchanged by the rename function (e.g. single-word names under rename="camel"), detection returns None and no key transformation runs — so nested structs whose field names do change are handed untransformed keys and fail to decode.

The outer struct is correctly configured. It is simply indistinguishable, by this heuristic, from a struct with no rename config at all.

Reproducer

import msgspec
from sqlspec.utils.schema import to_schema
from sqlspec.utils.type_guards import get_msgspec_rename_config


class Child(msgspec.Struct, rename="camel"):
    item_name: str
    item_count: int


class OuterSingleWord(msgspec.Struct, rename="camel"):
    rows: list[Child]      # camelize("rows")  == "rows"
    total: int             # camelize("total") == "total"


class OuterMultiWord(msgspec.Struct, rename="camel"):
    child_rows: list[Child]
    child_total: int


rows = [{"item_name": "a", "item_count": 1}]

print(get_msgspec_rename_config(OuterSingleWord))  # None   <-- misdetected
print(get_msgspec_rename_config(OuterMultiWord))   # camel
print(get_msgspec_rename_config(Child))            # camel

to_schema({"child_rows": rows, "child_total": 1}, schema_type=OuterMultiWord)  # OK
to_schema({"rows": rows, "total": 1}, schema_type=OuterSingleWord)             # raises

Output:

rename detected on OuterSingleWord: None
rename detected on OuterMultiWord : camel
rename detected on Child          : camel

-- OuterMultiWord (top-level name changes under camelize) --
OK: OuterMultiWord(child_rows=[Child(item_name='a', item_count=1)], child_total=1)

-- OuterSingleWord (top-level names unchanged under camelize) --
FAILED: ValidationError: Object missing required field `itemName` - at `$.rows[0]`

The error is the tell: msgspec asks for itemName while the payload still carries item_name, because no transformation ran.

Cause

get_msgspec_rename_config (sqlspec/utils/type_guards.py) returns on the first field whose name differs from its encode name, and None if none differ:

for field in fields:
    if field.name != field.encode_name:
        rename_config = _detect_rename_pattern(field.name, field.encode_name)
        _MSGSPEC_RENAME_CONFIG_CACHE[schema_type] = rename_config
        return rename_config

_MSGSPEC_RENAME_CONFIG_CACHE[schema_type] = None
return None

_convert_msgspec (sqlspec/utils/schema.py) then gates the recursive transform on that one verdict:

converter = _MSGSPEC_RENAME_CONVERTERS.get(rename_config) if rename_config else None
if converter:
    transformed_data = ... transform_dict_keys(item, converter) ...

So the root struct's field-name shape decides whether children get transformed, even though Child reports camel when asked directly.

The result cached in _MSGSPEC_RENAME_CONFIG_CACHE makes it sticky for the process.

Impact

Any response struct that wraps a list of rows in single-word fields — rows/total, items/count, data — silently fails to decode under rename="camel", while a semantically identical struct with multi-word field names works. The failure surfaces as a confusing ValidationError on a nested field, pointing away from the actual cause.

Renaming the outer fields to something multi-word is an effective workaround, which is an odd constraint to place on a public response shape.

Suggested direction

Either would fix it:

  1. Use the declared config rather than inferring it. The rename is known at class-definition time; the inference exists only because msgspec doesn't retain the original parameter. Deriving it per struct from encode_name for a known-multi-word probe, or threading the configuration explicitly, avoids the ambiguity.
  2. Resolve rename per nested struct rather than once at the root. Since Child already reports camel correctly when queried directly, transforming during a type-aware descent — rather than transforming the whole payload up front based on the root's verdict — would decode nested structs correctly regardless of the outer struct's field names.

Option 2 also fixes the mixed case, where an outer struct and a nested struct use different rename conventions.

Environment

  • sqlspec 0.58.2
  • msgspec (as pinned by sqlspec)
  • Python 3.12, Linux

Related

Possibly adjacent to #418 and #434, which covered schema_dump and msgspec rename handling in the serialization direction. This one is to_schema (deserialization) and a distinct mechanism — the recursive-transform gate — so it may not be covered by those fixes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions