Merge pull request #37 from HideyoshiNakazone/feature/implements-one-of

[FEATURE] Implements OneOf
This commit was merged in pull request #37.
This commit is contained in:
2025-08-19 20:45:30 -03:00
committed by GitHub
5 changed files with 703 additions and 1 deletions

112
docs/source/usage.oneof.rst Normal file
View File

@@ -0,0 +1,112 @@
OneOf Type
=================
The OneOf type is used to specify that an object must conform to exactly one of the specified schemas. Unlike AnyOf which allows matching multiple schemas, OneOf enforces that the data matches one and only one of the provided schemas.
Examples
-----------------
1. **Overlapping String Example** - A field that accepts strings with overlapping constraints:
.. code-block:: python
from jambo import SchemaConverter
schema = {
"title": "SimpleExample",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "maxLength": 6},
{"type": "string", "minLength": 4}
]
}
},
"required": ["value"]
}
Model = SchemaConverter.build(schema)
# Valid: Short string (matches first schema only)
obj1 = Model(value="hi")
print(obj1.value) # Output: hi
# Valid: Long string (matches second schema only)
obj2 = Model(value="very long string")
print(obj2.value) # Output: very long string
# Invalid: Medium string (matches BOTH schemas - violates oneOf)
try:
obj3 = Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
except ValueError as e:
print("Validation fails as expected:", e)
2. **Discriminator Example** - Different shapes with a type field:
.. code-block:: python
from jambo import SchemaConverter
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0}
},
"required": ["type", "radius"]
},
{
"type": "object",
"properties": {
"type": {"const": "rectangle"},
"width": {"type": "number", "minimum": 0},
"height": {"type": "number", "minimum": 0}
},
"required": ["type", "width", "height"]
}
],
"discriminator": {
"propertyName": "type"
}
}
},
"required": ["shape"]
}
Model = SchemaConverter.build(schema)
# Valid: Circle
circle = Model(shape={"type": "circle", "radius": 5.0})
print(circle.shape.type) # Output: circle
# Valid: Rectangle
rectangle = Model(shape={"type": "rectangle", "width": 10, "height": 20})
print(rectangle.shape.type) # Output: rectangle
# Invalid: Wrong properties for the type
try:
invalid = Model(shape={"type": "circle", "width": 10})
except ValueError as e:
print("Validation fails as expected:", e)
.. note::
OneOf ensures exactly one schema matches. The discriminator helps Pydantic efficiently determine which schema to use based on a specific property value.
.. warning::
If your data could match multiple schemas in a oneOf, validation will fail. Ensure schemas are mutually exclusive.
.. warning::
The discriminator feature is not officially in the JSON Schema specification, it was introduced by OpenAI. Use it with caution and ensure it fits your use case.

View File

@@ -45,5 +45,6 @@ For more complex schemas and types see our documentation on
usage.reference usage.reference
usage.allof usage.allof
usage.anyof usage.anyof
usage.oneof
usage.enum usage.enum
usage.const usage.const

View File

@@ -9,6 +9,7 @@ from .float_type_parser import FloatTypeParser
from .int_type_parser import IntTypeParser from .int_type_parser import IntTypeParser
from .null_type_parser import NullTypeParser from .null_type_parser import NullTypeParser
from .object_type_parser import ObjectTypeParser from .object_type_parser import ObjectTypeParser
from .oneof_type_parser import OneOfTypeParser
from .ref_type_parser import RefTypeParser from .ref_type_parser import RefTypeParser
from .string_type_parser import StringTypeParser from .string_type_parser import StringTypeParser
@@ -25,6 +26,7 @@ __all__ = [
"IntTypeParser", "IntTypeParser",
"NullTypeParser", "NullTypeParser",
"ObjectTypeParser", "ObjectTypeParser",
"OneOfTypeParser",
"StringTypeParser", "StringTypeParser",
"RefTypeParser", "RefTypeParser",
] ]

View File

@@ -0,0 +1,91 @@
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import BeforeValidator, Field, TypeAdapter, ValidationError
from typing_extensions import Annotated, Any, Union, Unpack
class OneOfTypeParser(GenericTypeParser):
mapped_type = Union
json_schema_type = "oneOf"
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
if "oneOf" not in properties:
raise ValueError(f"Invalid JSON Schema: {properties}")
if not isinstance(properties["oneOf"], list):
raise ValueError(f"Invalid JSON Schema: {properties['oneOf']}")
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
sub_properties = properties["oneOf"]
sub_types = [
GenericTypeParser.type_from_properties(name, subProperty, **kwargs)
for subProperty in sub_properties
]
if not kwargs.get("required", False):
mapped_properties["default"] = mapped_properties.get("default")
subfield_types = [Annotated[t, Field(**v)] for t, v in sub_types]
# Added with the understanding of discriminator are not in the JsonSchema Spec,
# they were added by OpenAI and not all implementations may support them,
# and they do not always generate a model one-to-one to the Pydantic model
# TL;DR: Discriminators were added by OpenAI and not a Official JSON Schema feature
discriminator = properties.get("discriminator")
if discriminator is not None:
validated_type = self._build_type_one_of_with_discriminator(
subfield_types, discriminator
)
else:
validated_type = self._build_type_one_of_with_func(subfield_types)
return validated_type, mapped_properties
@staticmethod
def _build_type_one_of_with_discriminator(
subfield_types: list[Annotated], discriminator_prop: dict
) -> Annotated:
"""
Build a type with a discriminator.
"""
if not isinstance(discriminator_prop, dict):
raise ValueError("Discriminator must be a dictionary")
property_name = discriminator_prop.get("propertyName")
if property_name is None or not isinstance(property_name, str):
raise ValueError("Discriminator must have a 'propertyName' key")
return Annotated[Union[(*subfield_types,)], Field(discriminator=property_name)]
@staticmethod
def _build_type_one_of_with_func(subfield_types: list[Annotated]) -> Annotated:
"""
Build a type with a validation function for the oneOf constraint.
"""
def validate_one_of(value: Any) -> Any:
matched_count = 0
for field_type in subfield_types:
try:
TypeAdapter(field_type).validate_python(value)
matched_count += 1
except ValidationError:
continue
if matched_count == 0:
raise ValueError("Value does not match any of the oneOf schemas")
elif matched_count > 1:
raise ValueError(
"Value matches multiple oneOf schemas, exactly one expected"
)
return value
return Annotated[Union[(*subfield_types,)], BeforeValidator(validate_one_of)]

View File

@@ -0,0 +1,496 @@
from jambo import SchemaConverter
from jambo.parser.oneof_type_parser import OneOfTypeParser
from unittest import TestCase
class TestOneOfTypeParser(TestCase):
def test_oneof_raises_on_invalid_property(self):
with self.assertRaises(ValueError):
OneOfTypeParser().from_properties_impl(
"test_field",
{
# Invalid schema, should have property "oneOf"
},
required=True,
context={},
ref_cache={},
)
with self.assertRaises(ValueError):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [], # should throw because oneOf requires at least one schema
}
},
}
)
def test_oneof_basic_integer_and_string(self):
schema = {
"title": "Person",
"description": "A person with an ID that can be either an integer or a formatted string",
"type": "object",
"properties": {
"id": {
"oneOf": [
{"type": "integer", "minimum": 1},
{"type": "string", "pattern": "^[A-Z]{2}[0-9]{4}$"},
]
},
},
"required": ["id"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(id=123)
self.assertEqual(obj1.id, 123)
obj2 = Model(id="AB1234")
self.assertEqual(obj2.id, "AB1234")
def test_oneof_validation_failures(self):
schema = {
"title": "Person",
"type": "object",
"properties": {
"id": {
"oneOf": [
{"type": "integer", "minimum": 1},
{"type": "string", "pattern": "^[A-Z]{2}[0-9]{4}$"},
]
},
},
"required": ["id"],
}
Model = SchemaConverter.build(schema)
with self.assertRaises(ValueError):
Model(id=-5)
with self.assertRaises(ValueError):
Model(id="invalid")
with self.assertRaises(ValueError):
Model(id=123.45)
def test_oneof_with_conflicting_schemas(self):
schema = {
"title": "Value",
"type": "object",
"properties": {
"data": {
"oneOf": [
{"type": "number", "multipleOf": 2},
{"type": "number", "multipleOf": 3},
]
},
},
"required": ["data"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(data=4)
self.assertEqual(obj1.data, 4)
obj2 = Model(data=9)
self.assertEqual(obj2.data, 9)
with self.assertRaises(ValueError) as cm:
Model(data=6)
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
with self.assertRaises(ValueError):
Model(data=5)
def test_oneof_with_objects(self):
schema = {
"title": "Contact",
"type": "object",
"properties": {
"contact_info": {
"oneOf": [
{
"type": "object",
"properties": {
"email": {"type": "string", "format": "email"}
},
"required": ["email"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"phone": {"type": "string", "pattern": "^[0-9-]+$"}
},
"required": ["phone"],
"additionalProperties": False,
},
]
},
},
"required": ["contact_info"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(contact_info={"email": "user@example.com"})
self.assertEqual(obj1.contact_info.email, "user@example.com")
obj2 = Model(contact_info={"phone": "123-456-7890"})
self.assertEqual(obj2.contact_info.phone, "123-456-7890")
with self.assertRaises(ValueError):
Model(contact_info={"email": "user@example.com", "phone": "123-456-7890"})
def test_oneof_with_discriminator_basic(self):
schema = {
"title": "Pet",
"type": "object",
"properties": {
"pet": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "cat"},
"meows": {"type": "boolean"},
},
"required": ["type", "meows"],
},
{
"type": "object",
"properties": {
"type": {"const": "dog"},
"barks": {"type": "boolean"},
},
"required": ["type", "barks"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["pet"],
}
Model = SchemaConverter.build(schema)
cat = Model(pet={"type": "cat", "meows": True})
self.assertEqual(cat.pet.type, "cat")
self.assertEqual(cat.pet.meows, True)
dog = Model(pet={"type": "dog", "barks": False})
self.assertEqual(dog.pet.type, "dog")
self.assertEqual(dog.pet.barks, False)
with self.assertRaises(ValueError):
Model(pet={"type": "cat", "barks": True})
with self.assertRaises(ValueError):
Model(pet={"type": "bird", "flies": True})
def test_oneof_with_discriminator_mapping(self):
schema = {
"title": "Vehicle",
"type": "object",
"properties": {
"vehicle": {
"oneOf": [
{
"type": "object",
"properties": {
"vehicle_type": {"const": "car"},
"doors": {
"type": "integer",
"minimum": 2,
"maximum": 4,
},
},
"required": ["vehicle_type", "doors"],
},
{
"type": "object",
"properties": {
"vehicle_type": {"const": "motorcycle"},
"engine_size": {"type": "number", "minimum": 125},
},
"required": ["vehicle_type", "engine_size"],
},
],
"discriminator": {
"propertyName": "vehicle_type",
"mapping": {
"car": "#/properties/vehicle/oneOf/0",
"motorcycle": "#/properties/vehicle/oneOf/1",
},
},
}
},
"required": ["vehicle"],
}
Model = SchemaConverter.build(schema)
car = Model(vehicle={"vehicle_type": "car", "doors": 4})
self.assertEqual(car.vehicle.vehicle_type, "car")
self.assertEqual(car.vehicle.doors, 4)
motorcycle = Model(vehicle={"vehicle_type": "motorcycle", "engine_size": 600.0})
self.assertEqual(motorcycle.vehicle.vehicle_type, "motorcycle")
self.assertEqual(motorcycle.vehicle.engine_size, 600.0)
def test_oneof_with_discriminator_invalid_values(self):
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0},
},
"required": ["type", "radius"],
},
{
"type": "object",
"properties": {
"type": {"const": "square"},
"side": {"type": "number", "minimum": 0},
},
"required": ["type", "side"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["shape"],
}
Model = SchemaConverter.build(schema)
with self.assertRaises(ValueError):
Model(shape={"type": "triangle", "base": 5, "height": 3})
with self.assertRaises(ValueError):
Model(shape={"type": "circle", "side": 5})
with self.assertRaises(ValueError):
Model(shape={"radius": 5})
def test_oneof_missing_properties(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"notOneOf": [
{"type": "string"},
{"type": "integer"},
]
},
},
}
with self.assertRaises(ValueError):
SchemaConverter.build(schema)
def test_oneof_invalid_properties(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {"oneOf": None},
},
}
with self.assertRaises(ValueError):
SchemaConverter.build(schema)
def test_oneof_with_default_value(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string"},
{"type": "integer"},
],
"default": "test",
},
},
}
Model = SchemaConverter.build(schema)
obj = Model()
self.assertEqual(obj.value, "test")
def test_oneof_with_invalid_default_value(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "minLength": 5},
{"type": "integer", "minimum": 10},
],
"default": "hi",
},
},
}
with self.assertRaises(ValueError):
SchemaConverter.build(schema)
def test_oneof_discriminator_without_property_name(self):
# Should throw because the spec determines propertyName is required for discriminator
with self.assertRaises(ValueError):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "a"},
"value": {"type": "string"},
},
},
{
"type": "object",
"properties": {
"type": {"const": "b"},
"value": {"type": "integer"},
},
},
],
"discriminator": {}, # discriminator without propertyName
}
},
}
)
def test_oneof_discriminator_with_invalid_discriminator(self):
# Should throw because a valid discriminator is required
with self.assertRaises(ValueError):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "a"},
"value": {"type": "string"},
},
},
{
"type": "object",
"properties": {
"type": {"const": "b"},
"value": {"type": "integer"},
},
},
],
"discriminator": "invalid", # discriminator without propertyName
}
},
}
)
def test_oneof_overlapping_strings_from_docs(self):
"""Test the overlapping strings example from documentation"""
schema = {
"title": "SimpleExample",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "maxLength": 6},
{"type": "string", "minLength": 4},
]
}
},
"required": ["value"],
}
Model = SchemaConverter.build(schema)
# Valid: Short string (matches first schema only)
obj1 = Model(value="hi")
self.assertEqual(obj1.value, "hi")
# Valid: Long string (matches second schema only)
obj2 = Model(value="very long string")
self.assertEqual(obj2.value, "very long string")
# Invalid: Medium string (matches BOTH schemas - violates oneOf)
with self.assertRaises(ValueError) as cm:
Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
def test_oneof_shapes_discriminator_from_docs(self):
"""Test the shapes discriminator example from documentation"""
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0},
},
"required": ["type", "radius"],
},
{
"type": "object",
"properties": {
"type": {"const": "rectangle"},
"width": {"type": "number", "minimum": 0},
"height": {"type": "number", "minimum": 0},
},
"required": ["type", "width", "height"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["shape"],
}
Model = SchemaConverter.build(schema)
# Valid: Circle
circle = Model(shape={"type": "circle", "radius": 5.0})
self.assertEqual(circle.shape.type, "circle")
self.assertEqual(circle.shape.radius, 5.0)
# Valid: Rectangle
rectangle = Model(shape={"type": "rectangle", "width": 10, "height": 20})
self.assertEqual(rectangle.shape.type, "rectangle")
self.assertEqual(rectangle.shape.width, 10)
self.assertEqual(rectangle.shape.height, 20)
# Invalid: Wrong properties for the type
with self.assertRaises(ValueError):
Model(shape={"type": "circle", "width": 10})