Skip to content

Commit 992af20

Browse files
authored
Fix type loss when $ref is used with non-standard metadata fields (#2993)
* Fix type loss when $ref is used with non-standard metadata fields * Rename test to match updated behavior
1 parent 0f1bc0f commit 992af20

9 files changed

Lines changed: 185 additions & 17 deletions

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,10 @@ def model_rebuild(cls) -> None:
268268
"dynamicAnchor",
269269
}
270270

271+
__schema_affecting_extras__: set[str] = { # noqa: RUF012
272+
"const",
273+
}
274+
271275
@model_validator(mode="before")
272276
def validate_exclusive_maximum_and_exclusive_minimum(cls, values: Any) -> Any: # noqa: N805
273277
"""Validate and convert boolean exclusive maximum and minimum to numeric values."""
@@ -489,10 +493,7 @@ def has_ref_with_schema_keywords(self) -> bool:
489493
other_fields = get_fields_set(self) - {"ref"}
490494
schema_affecting_fields = other_fields - self.__metadata_only_fields__ - {"extras"}
491495
if self.extras:
492-
# Filter out metadata-only fields AND extension fields (x-* prefix)
493-
schema_affecting_extras = {
494-
k for k in self.extras if k not in self.__metadata_only_fields__ and not k.startswith("x-")
495-
}
496+
schema_affecting_extras = {k for k in self.extras if k in self.__schema_affecting_extras__}
496497
if schema_affecting_extras:
497498
schema_affecting_fields |= {"extras"}
498499
return bool(schema_affecting_fields)
@@ -511,9 +512,7 @@ def is_ref_with_nullable_only(self) -> bool:
511512
if other_fields:
512513
return False
513514
if self.extras:
514-
schema_affecting_extras = {
515-
k for k in self.extras if k not in self.__metadata_only_fields__ and not k.startswith("x-")
516-
}
515+
schema_affecting_extras = {k for k in self.extras if k in self.__schema_affecting_extras__}
517516
if schema_affecting_extras:
518517
return False
519518
return True

tests/data/expected/main/jsonschema/ref_nullable_with_extra.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,9 @@
77
from pydantic import BaseModel
88

99

10-
class UserWithExtra(BaseModel):
10+
class User(BaseModel):
1111
name: str | None = None
1212

1313

1414
class Model(BaseModel):
15-
user_with_extra: UserWithExtra | None = None
16-
17-
18-
class User(BaseModel):
19-
name: str | None = None
15+
user_with_extra: User | None = None
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# generated by datamodel-codegen:
2+
# filename: ref_nullable_with_nonstandard_metadata.yaml
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 User(BaseModel):
11+
name: str | None = None
12+
13+
14+
class Model(BaseModel):
15+
user: User | None = None
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# generated by datamodel-codegen:
2+
# filename: ref_with_const.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
from typing import Literal
9+
10+
from pydantic import BaseModel
11+
12+
13+
class Status(Enum):
14+
active = 'active'
15+
inactive = 'inactive'
16+
17+
18+
class NullableStatus(Enum):
19+
active = 'active'
20+
inactive = 'inactive'
21+
22+
23+
class Model(BaseModel):
24+
status: Literal['active']
25+
nullable_status: Literal['active'] = 'active'
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# generated by datamodel-codegen:
2+
# filename: ref_with_nonstandard_metadata.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from ipaddress import IPv6Address
8+
9+
from pydantic import BaseModel, RootModel
10+
11+
12+
class Ipv6Addr(RootModel[IPv6Address]):
13+
root: IPv6Address
14+
15+
16+
class Model(BaseModel):
17+
ipv6_address: Ipv6Addr
18+
ipv6_address_nullable: Ipv6Addr | None = None
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
$schema: "http://json-schema.org/draft-07/schema#"
2+
definitions:
3+
User:
4+
type: object
5+
properties:
6+
name:
7+
type: string
8+
type: object
9+
properties:
10+
user:
11+
$ref: "#/definitions/User"
12+
nullable: true
13+
markdownDescription: "A user object"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"definitions": {
4+
"Status": {
5+
"type": "string",
6+
"enum": ["active", "inactive"]
7+
}
8+
},
9+
"type": "object",
10+
"properties": {
11+
"status": {
12+
"$ref": "#/definitions/Status",
13+
"const": "active"
14+
},
15+
"nullable_status": {
16+
"$ref": "#/definitions/Status",
17+
"nullable": true,
18+
"const": "active"
19+
}
20+
},
21+
"required": ["status"]
22+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"definitions": {
4+
"Ipv6Addr": {
5+
"type": "string",
6+
"format": "ipv6"
7+
}
8+
},
9+
"type": "object",
10+
"properties": {
11+
"ipv6_address": {
12+
"$ref": "#/definitions/Ipv6Addr",
13+
"markdownDescription": "An IPv6 address"
14+
},
15+
"ipv6_address_nullable": {
16+
"anyOf": [
17+
{
18+
"$ref": "#/definitions/Ipv6Addr"
19+
},
20+
{
21+
"type": "null"
22+
}
23+
],
24+
"markdownDescription": "An optional IPv6 address"
25+
}
26+
},
27+
"required": ["ipv6_address"]
28+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7912,11 +7912,12 @@ def test_ref_nullable_with_constraint_creates_model(output_file: Path) -> None:
79127912
)
79137913

79147914

7915-
def test_ref_nullable_with_extra_creates_model(output_file: Path) -> None:
7916-
"""Test $ref + nullable: true + schema-affecting extras DOES create a merged model.
7915+
def test_ref_nullable_with_extra_uses_reference_directly(output_file: Path) -> None:
7916+
"""Test $ref + nullable: true + non-schema-affecting extras uses reference directly.
79177917
7918-
When a property has $ref with nullable: true AND schema-affecting extras like
7919-
'if', 'then', 'else', it should merge the schemas and create a new model.
7918+
When a property has $ref with nullable: true AND extras that the tool cannot
7919+
structurally process (like 'if'), it should use the reference directly
7920+
instead of creating a merged model.
79207921
"""
79217922
run_main_and_assert(
79227923
input_path=JSON_SCHEMA_DATA_PATH / "ref_nullable_with_extra.yaml",
@@ -8539,3 +8540,54 @@ def test_main_jsonschema_multiple_aliases_required_pydantic_v2(output_file: Path
85398540
"pydantic_v2.BaseModel",
85408541
],
85418542
)
8543+
8544+
8545+
def test_ref_with_nonstandard_metadata(output_file: Path) -> None:
8546+
"""Test $ref with non-standard metadata fields preserves type information.
8547+
8548+
When $ref is combined with non-standard metadata like 'markdownDescription',
8549+
the reference type should be preserved instead of being replaced by the
8550+
underlying type. Non-standard fields are annotation-only and should not
8551+
trigger schema merging.
8552+
"""
8553+
run_main_and_assert(
8554+
input_path=JSON_SCHEMA_DATA_PATH / "ref_with_nonstandard_metadata.json",
8555+
output_path=output_file,
8556+
input_file_type="jsonschema",
8557+
assert_func=assert_file_content,
8558+
expected_file="ref_with_nonstandard_metadata.py",
8559+
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--use-annotated"],
8560+
)
8561+
8562+
8563+
def test_ref_nullable_with_nonstandard_metadata(output_file: Path) -> None:
8564+
"""Test $ref + nullable: true with non-standard metadata uses reference directly.
8565+
8566+
When $ref is combined with nullable: true and non-standard metadata like
8567+
'markdownDescription', the reference should be used directly with Optional
8568+
type annotation instead of creating a merged model.
8569+
"""
8570+
run_main_and_assert(
8571+
input_path=JSON_SCHEMA_DATA_PATH / "ref_nullable_with_nonstandard_metadata.yaml",
8572+
output_path=output_file,
8573+
input_file_type="jsonschema",
8574+
assert_func=assert_file_content,
8575+
expected_file="ref_nullable_with_nonstandard_metadata.py",
8576+
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--strict-nullable"],
8577+
)
8578+
8579+
8580+
def test_ref_with_const(output_file: Path) -> None:
8581+
"""Test $ref + const triggers schema merging as const is schema-affecting.
8582+
8583+
When $ref is combined with 'const', the const keyword structurally affects
8584+
the generated type (producing Literal), so schema merging should occur.
8585+
"""
8586+
run_main_and_assert(
8587+
input_path=JSON_SCHEMA_DATA_PATH / "ref_with_const.json",
8588+
output_path=output_file,
8589+
input_file_type="jsonschema",
8590+
assert_func=assert_file_content,
8591+
expected_file="ref_with_const.py",
8592+
extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--strict-nullable"],
8593+
)

0 commit comments

Comments
 (0)