Fix allOf/oneOf edge cases and add comprehensive tests

Bugs fixed:
- additionalProperties: {} now treated as true (per JSON Schema spec)
- Sibling properties override allOf children (local > inherited)
- additionalProperties: false wins when allOf children conflict
- anyOf guard added to typeless-properties shortcircuit

New test coverage:
- Empty additionalProperties schema (with and without properties)
- Sibling property precedence over allOf children
- additionalProperties intersection in allOf
- oneOf with null branch
- properties + anyOf not shortcircuited
- allOf with only required (no properties)
- oneOf with empty additionalProperties dict
- allOf with sibling additionalProperties: true

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
strawgate 2026-05-13 00:59:33 -05:00
commit 025ed3594f
2 changed files with 160 additions and 7 deletions

View file

@ -403,6 +403,10 @@ def _object_schema_to_type(
"""
has_properties = bool(schema.get("properties"))
additional_props = schema.get("additionalProperties")
# Per JSON Schema, an empty schema {} means "accept anything" —
# equivalent to additionalProperties: true.
if isinstance(additional_props, dict) and not additional_props:
additional_props = True
class_name = name if name is not None else schema.get("title")
if not has_properties and additional_props:
@ -458,6 +462,7 @@ def _schema_to_type(
and "properties" in schema
and "allOf" not in schema
and "oneOf" not in schema
and "anyOf" not in schema
):
return _create_dataclass(schema, schema.get("title", "<unknown>"), schemas)
@ -529,19 +534,25 @@ def _schema_to_type(
_collect_allof(nested)
merged_properties.update(sub.get("properties", {}))
merged_required.extend(sub.get("required", []))
for key in ("title", "description", "additionalProperties"):
for key in ("title", "description"):
if key in sub and key not in merged:
merged[key] = sub[key]
# Intersect additionalProperties: false is most restrictive and wins.
if "additionalProperties" in sub:
existing = merged.get("additionalProperties")
if existing is None:
merged["additionalProperties"] = sub["additionalProperties"]
elif sub["additionalProperties"] is False:
merged["additionalProperties"] = False
# Include sibling properties/required from the schema itself,
# not just from allOf children — covers schemas where top-level
# properties coexist with an allOf list of inherited properties.
merged_properties.update(schema.get("properties", {}))
merged_required.extend(schema.get("required", []))
# Collect from allOf children first, then overlay sibling
# properties so local definitions take precedence over inherited.
for sub in schema["allOf"]:
_collect_allof(sub)
merged_properties.update(schema.get("properties", {}))
merged_required.extend(schema.get("required", []))
if has_false:
return _UnsatisfiableType # type: ignore[return-value]

View file

@ -649,3 +649,145 @@ class TestAllOfOneOf:
with pytest.raises(ValidationError):
ta.validate_python({"name": "Alice"})
def test_additional_properties_empty_schema_is_allow_any(self):
"""additionalProperties: {} is equivalent to additionalProperties: true per spec."""
schema = {
"type": "object",
"additionalProperties": {},
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
# Should accept any values (empty schema = true = allow everything)
assert ta.validate_python({"x": 1, "y": "two"}) == {"x": 1, "y": "two"}
def test_additional_properties_empty_schema_with_properties(self):
"""properties + additionalProperties: {} should produce Pydantic model (extra=allow)."""
from pydantic import BaseModel
schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
"additionalProperties": {},
}
T = json_schema_to_type(schema)
assert issubclass(T, BaseModel)
ta = TypeAdapter(T)
result = ta.validate_python({"name": "Alice", "extra_key": 42})
assert result.name == "Alice"
def test_sibling_properties_override_allof(self):
"""Sibling properties should take precedence over allOf children."""
schema = {
"properties": {"name": {"type": "integer"}},
"allOf": [
{"type": "object", "properties": {"name": {"type": "string"}}},
],
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
# Sibling says name is int, allOf child says str — sibling wins
result = ta.validate_python({"name": 42})
assert result.name == 42
def test_allof_additional_properties_false_wins(self):
"""When allOf children conflict on additionalProperties, false should win."""
schema = {
"allOf": [
{
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": True,
},
{
"type": "object",
"properties": {"b": {"type": "integer"}},
"additionalProperties": False,
},
],
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
result = ta.validate_python({"a": "hello", "b": 1})
assert result.a == "hello"
assert result.b == 1
def test_oneof_with_null_branch(self):
"""oneOf with null branch should produce Optional type."""
schema = {
"oneOf": [
{"type": "string"},
{"type": "null"},
]
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
assert ta.validate_python("hello") == "hello"
assert ta.validate_python(None) is None
def test_properties_with_anyof_not_shortcircuited(self):
"""Schema with properties + anyOf should NOT shortcircuit to dataclass."""
schema = {
"properties": {"base": {"type": "string"}},
"anyOf": [
{"properties": {"variant_a": {"type": "integer"}}},
{"properties": {"variant_b": {"type": "boolean"}}},
],
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
result = ta.validate_python({"base": "val"})
assert result is not None
def test_allof_with_only_required_no_properties(self):
"""allOf child with only required (no properties) merges correctly."""
schema = {
"allOf": [
{
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
},
{"required": ["name"]},
]
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
result = ta.validate_python({"name": "Alice"})
assert result.name == "Alice"
with pytest.raises(ValidationError):
ta.validate_python({"age": 30})
def test_oneof_with_empty_additional_properties_dict(self):
"""oneOf branch with additionalProperties: {} should produce dict[str, Any]."""
schema = {
"oneOf": [
{"type": "string"},
{"type": "object", "additionalProperties": {}},
]
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
assert ta.validate_python("hello") == "hello"
assert ta.validate_python({"x": 1}) == {"x": 1}
def test_allof_with_sibling_additional_properties_true(self):
"""properties + additionalProperties: true + allOf should preserve extra keys."""
from pydantic import BaseModel
schema = {
"additionalProperties": True,
"allOf": [
{
"type": "object",
"properties": {"name": {"type": "string"}},
},
],
}
T = json_schema_to_type(schema)
assert issubclass(T, BaseModel)
ta = TypeAdapter(T)
result = ta.validate_python({"name": "Alice", "extra": "kept"})
assert result.name == "Alice"