Skip to content

Commit d4adf40

Browse files
authored
Fix MRO ordering for multiple inheritance in GraphQL and JSON Schema/OpenAPI (#2941)
* Fix GraphQL interface MRO ordering for multiple inheritance * Support MRO sorting for JSON Schema/OpenAPI allOf inheritance * Refactor MRO sort loop to reduce nesting * Remove redundant GraphQL MRO sort (handled by base.py)
1 parent 60f7335 commit d4adf40

11 files changed

Lines changed: 501 additions & 0 deletions

File tree

src/datamodel_code_generator/parser/base.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,71 @@ def sort_data_models( # noqa: PLR0912, PLR0915
485485
return unresolved_references, sorted_data_models, require_update_action_models
486486

487487

488+
def sort_base_classes_for_mro(sorted_data_models: SortedDataModels) -> None:
489+
"""Sort base classes in each model to ensure valid Python MRO.
490+
491+
When a class inherits from multiple base classes where some bases inherit
492+
from others, Python's C3 linearization requires that child classes appear
493+
before their parent classes in the inheritance list.
494+
495+
For example, if B inherits from A, then class C(A, B) is invalid but
496+
class C(B, A) is valid.
497+
"""
498+
for model in sorted_data_models.values():
499+
base_classes = model.base_classes
500+
if len(base_classes) <= 1:
501+
continue
502+
503+
# Build set of base class paths for quick lookup
504+
base_class_paths = {b.reference.path for b in base_classes if b.reference}
505+
506+
def get_ancestors(
507+
ref_path: str,
508+
base_class_paths: set[str] = base_class_paths,
509+
) -> set[str]:
510+
"""Get all ancestor paths that are in our base class list."""
511+
ancestors: set[str] = set()
512+
source_model = sorted_data_models.get(ref_path)
513+
if source_model is None: # pragma: no cover
514+
return ancestors
515+
to_visit = [
516+
bc.reference.path
517+
for bc in source_model.base_classes
518+
if bc.reference and bc.reference.path in base_class_paths
519+
]
520+
while to_visit:
521+
parent_path = to_visit.pop()
522+
if parent_path in ancestors:
523+
continue
524+
ancestors.add(parent_path)
525+
parent_model = sorted_data_models.get(parent_path)
526+
if not parent_model:
527+
continue
528+
to_visit.extend(
529+
bc.reference.path
530+
for bc in parent_model.base_classes
531+
if bc.reference and bc.reference.path in base_class_paths
532+
)
533+
return ancestors
534+
535+
# Build ancestor map for each base class
536+
ancestor_map = {b.reference.path: get_ancestors(b.reference.path) for b in base_classes if b.reference}
537+
538+
def sort_key(
539+
bc: BaseClassDataType,
540+
ancestor_map: dict[str, set[str]] = ancestor_map,
541+
) -> int:
542+
"""Sort key: classes that are ancestors of others come later."""
543+
if not bc.reference:
544+
return 0
545+
path = bc.reference.path
546+
# Count how many other base classes have this one as an ancestor
547+
return sum(1 for other_path in ancestor_map if path in ancestor_map.get(other_path, set()))
548+
549+
# Use stable sort to preserve original order for elements with equal keys
550+
model.base_classes = sorted(base_classes, key=sort_key)
551+
552+
488553
def relative(
489554
current_module: str,
490555
reference: str,
@@ -3161,6 +3226,7 @@ def parse( # noqa: PLR0913, PLR0914, PLR0917
31613226
)
31623227

31633228
_, sorted_data_models, require_update_action_models = sort_data_models(self.results)
3229+
sort_base_classes_for_mro(sorted_data_models)
31643230

31653231
(
31663232
module_models,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# generated by datamodel-codegen:
2+
# filename: interface_mro.graphql
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated, Literal
8+
9+
from pydantic import BaseModel, Field
10+
from typing_extensions import TypeAliasType
11+
12+
Boolean = TypeAliasType("Boolean", bool)
13+
"""
14+
The `Boolean` scalar type represents `true` or `false`.
15+
"""
16+
17+
18+
ID = TypeAliasType("ID", str)
19+
"""
20+
The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID.
21+
"""
22+
23+
24+
String = TypeAliasType("String", str)
25+
"""
26+
The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.
27+
"""
28+
29+
30+
class BaseInterface(BaseModel):
31+
base_field: Annotated[String | None, Field(alias='baseField')] = None
32+
typename__: Annotated[
33+
Literal['BaseInterface'] | None, Field(alias='__typename')
34+
] = 'BaseInterface'
35+
36+
37+
class Entity(BaseModel):
38+
id: ID
39+
typename__: Annotated[Literal['Entity'] | None, Field(alias='__typename')] = (
40+
'Entity'
41+
)
42+
43+
44+
class GrandParent(BaseModel):
45+
grand_field: Annotated[String | None, Field(alias='grandField')] = None
46+
typename__: Annotated[Literal['GrandParent'] | None, Field(alias='__typename')] = (
47+
'GrandParent'
48+
)
49+
50+
51+
class LeftBranch(BaseInterface):
52+
base_field: Annotated[String | None, Field(alias='baseField')] = None
53+
left_field: Annotated[String | None, Field(alias='leftField')] = None
54+
typename__: Annotated[Literal['LeftBranch'] | None, Field(alias='__typename')] = (
55+
'LeftBranch'
56+
)
57+
58+
59+
class Node(BaseModel):
60+
id: ID
61+
typename__: Annotated[Literal['Node'] | None, Field(alias='__typename')] = 'Node'
62+
63+
64+
class Notification(Node):
65+
id: ID
66+
read_at: Annotated[String | None, Field(alias='readAt')] = None
67+
typename__: Annotated[Literal['Notification'] | None, Field(alias='__typename')] = (
68+
'Notification'
69+
)
70+
71+
72+
class Parent(GrandParent):
73+
grand_field: Annotated[String | None, Field(alias='grandField')] = None
74+
parent_field: Annotated[String | None, Field(alias='parentField')] = None
75+
typename__: Annotated[Literal['Parent'] | None, Field(alias='__typename')] = (
76+
'Parent'
77+
)
78+
79+
80+
class RightBranch(BaseInterface):
81+
base_field: Annotated[String | None, Field(alias='baseField')] = None
82+
right_field: Annotated[String | None, Field(alias='rightField')] = None
83+
typename__: Annotated[Literal['RightBranch'] | None, Field(alias='__typename')] = (
84+
'RightBranch'
85+
)
86+
87+
88+
class CustomerNeedNotification(Entity, Notification, Node):
89+
customer: String | None = None
90+
id: ID
91+
read_at: Annotated[String | None, Field(alias='readAt')] = None
92+
typename__: Annotated[
93+
Literal['CustomerNeedNotification'] | None, Field(alias='__typename')
94+
] = 'CustomerNeedNotification'
95+
96+
97+
class DiamondType(LeftBranch, RightBranch, BaseInterface):
98+
base_field: Annotated[String | None, Field(alias='baseField')] = None
99+
diamond_field: Annotated[String | None, Field(alias='diamondField')] = None
100+
left_field: Annotated[String | None, Field(alias='leftField')] = None
101+
right_field: Annotated[String | None, Field(alias='rightField')] = None
102+
typename__: Annotated[Literal['DiamondType'] | None, Field(alias='__typename')] = (
103+
'DiamondType'
104+
)
105+
106+
107+
class Child(Parent, GrandParent):
108+
child_field: Annotated[String | None, Field(alias='childField')] = None
109+
grand_field: Annotated[String | None, Field(alias='grandField')] = None
110+
parent_field: Annotated[String | None, Field(alias='parentField')] = None
111+
typename__: Annotated[Literal['Child'] | None, Field(alias='__typename')] = 'Child'
112+
113+
114+
class LeafType(Child, Parent, GrandParent):
115+
child_field: Annotated[String | None, Field(alias='childField')] = None
116+
grand_field: Annotated[String | None, Field(alias='grandField')] = None
117+
leaf_field: Annotated[String | None, Field(alias='leafField')] = None
118+
parent_field: Annotated[String | None, Field(alias='parentField')] = None
119+
typename__: Annotated[Literal['LeafType'] | None, Field(alias='__typename')] = (
120+
'LeafType'
121+
)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# generated by datamodel-codegen:
2+
# filename: union_class_name_prefix.graphql
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated, Literal, Union
8+
9+
from pydantic import BaseModel, Field
10+
from typing_extensions import TypeAliasType
11+
12+
FooBoolean = TypeAliasType("FooBoolean", bool)
13+
"""
14+
The `Boolean` scalar type represents `true` or `false`.
15+
"""
16+
17+
18+
FooID = TypeAliasType("FooID", str)
19+
"""
20+
The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID.
21+
"""
22+
23+
24+
FooInt = TypeAliasType("FooInt", int)
25+
"""
26+
The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
27+
"""
28+
29+
30+
FooString = TypeAliasType("FooString", str)
31+
"""
32+
The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.
33+
"""
34+
35+
36+
class FooIResource(BaseModel):
37+
id: FooID
38+
typename__: Annotated[Literal['IResource'] | None, Field(alias='__typename')] = (
39+
'IResource'
40+
)
41+
42+
43+
class FooCar(FooIResource):
44+
id: FooID
45+
passenger_capacity: Annotated[FooInt, Field(alias='passengerCapacity')]
46+
typename__: Annotated[Literal['Car'] | None, Field(alias='__typename')] = 'Car'
47+
48+
49+
class FooEmployee(FooIResource):
50+
first_name: Annotated[FooString | None, Field(alias='firstName')] = None
51+
id: FooID
52+
last_name: Annotated[FooString | None, Field(alias='lastName')] = None
53+
typename__: Annotated[Literal['Employee'] | None, Field(alias='__typename')] = (
54+
'Employee'
55+
)
56+
57+
58+
FooResource = TypeAliasType(
59+
"FooResource",
60+
Union[
61+
'FooCar',
62+
'FooEmployee',
63+
],
64+
)
65+
66+
67+
FooTechnicalResource = TypeAliasType("FooTechnicalResource", FooCar)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# generated by datamodel-codegen:
2+
# filename: allof_mro.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel
8+
9+
10+
class BaseInterface(BaseModel):
11+
id: str | None = None
12+
13+
14+
class LeftBranch(BaseInterface):
15+
left_value: str | None = None
16+
17+
18+
class RightBranch(BaseInterface):
19+
right_value: str | None = None
20+
21+
22+
class DiamondType(LeftBranch, RightBranch, BaseInterface):
23+
"""
24+
Diamond inheritance pattern - inherits from both LeftBranch and RightBranch which both inherit from BaseInterface
25+
"""
26+
27+
diamond_value: str | None = None
28+
29+
30+
class Model(DiamondType):
31+
pass
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# generated by datamodel-codegen:
2+
# filename: unknown_format.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel
8+
9+
10+
class Model(BaseModel):
11+
data: str | None = None
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
interface Entity {
2+
id: ID!
3+
}
4+
5+
interface Node {
6+
id: ID!
7+
}
8+
9+
interface Notification implements Node {
10+
id: ID!
11+
readAt: String
12+
}
13+
14+
type CustomerNeedNotification implements Entity & Node & Notification {
15+
id: ID!
16+
readAt: String
17+
customer: String
18+
}
19+
20+
interface BaseInterface {
21+
baseField: String
22+
}
23+
24+
interface LeftBranch implements BaseInterface {
25+
baseField: String
26+
leftField: String
27+
}
28+
29+
interface RightBranch implements BaseInterface {
30+
baseField: String
31+
rightField: String
32+
}
33+
34+
type DiamondType implements BaseInterface & LeftBranch & RightBranch {
35+
baseField: String
36+
leftField: String
37+
rightField: String
38+
diamondField: String
39+
}
40+
41+
interface GrandParent {
42+
grandField: String
43+
}
44+
45+
interface Parent implements GrandParent {
46+
grandField: String
47+
parentField: String
48+
}
49+
50+
interface Child implements GrandParent & Parent {
51+
grandField: String
52+
parentField: String
53+
childField: String
54+
}
55+
56+
type LeafType implements GrandParent & Parent & Child {
57+
grandField: String
58+
parentField: String
59+
childField: String
60+
leafField: String
61+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
interface IResource {
2+
id: ID!
3+
}
4+
5+
type Employee implements IResource {
6+
id: ID!
7+
firstName: String
8+
lastName: String
9+
}
10+
11+
type Car implements IResource {
12+
id: ID!
13+
passengerCapacity: Int!
14+
}
15+
16+
union Resource = Employee | Car
17+
18+
union TechnicalResource = Car

0 commit comments

Comments
 (0)