Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Release type: patch

Fix `strawberry.experimental.pydantic` to correctly handle nested `pydantic.v1`
models when running on Pydantic 2 (for example, `list[LegacyModel]` fields with
`all_fields=True`), and add a regression test for this case.
28 changes: 28 additions & 0 deletions strawberry/experimental/pydantic/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@
IS_PYDANTIC_V1: bool = not IS_PYDANTIC_V2


def _get_base_model_types() -> tuple[type[Any], ...]:
model_types: list[type[Any]] = [BaseModel]

try:
from pydantic.v1 import BaseModel as BaseModelV1
except ImportError:
return tuple(model_types)

if BaseModelV1 not in model_types:
model_types.append(BaseModelV1)

return tuple(model_types)


BASE_MODEL_TYPES = _get_base_model_types()


@dataclass
class CompatModelField:
name: str
Expand Down Expand Up @@ -318,10 +335,21 @@ def new_type_supertype(type_: Any) -> Any:
smart_deepcopy,
)


def is_model_class(type_: Any) -> bool:
return lenient_issubclass(type_, BASE_MODEL_TYPES)


def is_model_instance(value: Any) -> bool:
return isinstance(value, BASE_MODEL_TYPES)


__all__ = [
"PydanticCompat",
"get_args",
"get_origin",
"is_model_class",
"is_model_instance",
"is_new_type",
"lenient_issubclass",
"new_type_supertype",
Expand Down
10 changes: 5 additions & 5 deletions strawberry/experimental/pydantic/error_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@
cast,
)

from pydantic import BaseModel

from strawberry.experimental.pydantic._compat import (
CompatModelField,
PydanticCompat,
lenient_issubclass,
is_model_class,
)
from strawberry.experimental.pydantic.utils import (
get_private_fields,
Expand All @@ -31,6 +29,8 @@
if TYPE_CHECKING:
from collections.abc import Callable, Sequence

from pydantic import BaseModel

from strawberry.types.base import WithStrawberryObjectDefinition


Expand All @@ -49,13 +49,13 @@ def field_type_to_type(type_: type) -> Any | list[Any] | None:

if is_list(child_type):
strawberry_type = field_type_to_type(child_type)
elif lenient_issubclass(child_type, BaseModel):
elif is_model_class(child_type):
strawberry_type = get_strawberry_type_from_model(child_type)
else:
strawberry_type = list[error_class]

strawberry_type = Optional[strawberry_type] # noqa: UP045
elif lenient_issubclass(type_, BaseModel):
elif is_model_class(type_):
strawberry_type = get_strawberry_type_from_model(type_)
return Optional[strawberry_type] # noqa: UP045

Expand Down
6 changes: 2 additions & 4 deletions strawberry/experimental/pydantic/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@
)
from typing import GenericAlias as TypingGenericAlias # type: ignore

from pydantic import BaseModel

from strawberry.experimental.pydantic._compat import (
PydanticCompat,
get_args,
get_origin,
lenient_issubclass,
is_model_class,
)
from strawberry.experimental.pydantic.exceptions import (
UnregisteredTypeException,
Expand All @@ -22,7 +20,7 @@


def replace_pydantic_types(type_: Any, is_input: bool) -> Any:
if lenient_issubclass(type_, BaseModel):
if is_model_class(type_):
attr = "_strawberry_input_type" if is_input else "_strawberry_type"
if hasattr(type_, attr):
return getattr(type_, attr)
Expand Down
6 changes: 3 additions & 3 deletions strawberry/experimental/pydantic/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@
cast,
)

from pydantic import BaseModel

from strawberry.experimental.pydantic._compat import (
CompatModelField,
PydanticCompat,
is_model_instance,
smart_deepcopy,
)
from strawberry.experimental.pydantic.exceptions import (
Expand All @@ -30,6 +29,7 @@
)

if TYPE_CHECKING:
from pydantic import BaseModel
from pydantic.typing import NoArgAnyCallable


Expand Down Expand Up @@ -102,7 +102,7 @@ def get_default_factory_for_field(
# if the default value is a pydantic base model
# we should return the serialized version of that default for
# printing the value.
if isinstance(default, BaseModel):
if is_model_instance(default):
return lambda: compat.model_dump(default)
return lambda: smart_deepcopy(default)

Expand Down
45 changes: 45 additions & 0 deletions tests/experimental/pydantic/schema/test_1_and_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,48 @@ def user(self, id: strawberry.ID) -> User | LegacyUser:

assert not result.errors
assert result.data == {"user": {"__typename": "LegacyUser", "name": "legacy"}}


@pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Pydantic v1 is not compatible with Python 3.14+",
)
@needs_pydantic_v2
def test_can_use_nested_pydantic_v1_models():
from pydantic import v1 as pydantic_v1

class Book(pydantic_v1.BaseModel):
title: str

class Library(pydantic_v1.BaseModel):
books: list[Book]

@strawberry.experimental.pydantic.type(model=Book, all_fields=True)
class BookType:
pass

@strawberry.experimental.pydantic.type(model=Library, all_fields=True)
class LibraryType:
pass

@strawberry.type
class Query:
library: LibraryType

schema = strawberry.Schema(query=Query)

expected_schema = """
type BookType {
title: String!
}

type LibraryType {
books: [BookType!]!
}

type Query {
library: LibraryType!
}
"""

assert str(schema) == textwrap.dedent(expected_schema).strip()
Comment on lines +97 to +134
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test only validates schema SDL, not query execution

The test verifies that the schema SDL is generated correctly for nested pydantic v1 models, which is the core regression being fixed. However, it does not exercise the conversion path (e.g., from_pydantic, resolving a query with a real Library instance containing Book objects). The existing test_can_use_both_pydantic_1_and_2 test includes both schema string validation and query execution.

Consider adding an execution assertion to also cover the replace_types_recursively path at runtime, e.g.:

@strawberry.type
class Query:
    @strawberry.field
    def library(self) -> LibraryType:
        return LibraryType.from_pydantic(Library(books=[Book(title="Test")]))

schema = strawberry.Schema(query=Query)
result = schema.execute_sync("{ library { books { title } } }")
assert not result.errors
assert result.data == {"library": {"books": [{"title": "Test"}]}}

This would validate the full conversion pipeline for nested v1 models, not just schema generation.

Context Used: Context from dashboard - In tests involving Pydantic models, ensure that the tests accurately reflect the intended behavior b... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Loading