diff --git a/singer_sdk/helpers/_typing.py b/singer_sdk/helpers/_typing.py index b9beca6cd..db4c88820 100644 --- a/singer_sdk/helpers/_typing.py +++ b/singer_sdk/helpers/_typing.py @@ -80,10 +80,12 @@ def append_type(type_dict: dict, new_type: str) -> dict: """Return a combined type definition using the 'anyOf' JSON Schema construct.""" result = copy.deepcopy(type_dict) if "anyOf" in result: - if isinstance(result["anyOf"], list) and new_type not in result["anyOf"]: - result["anyOf"].append(new_type) - elif new_type != result["anyOf"]: - result["anyOf"] = [result["anyOf"], new_type] + new_type_dict = {"type": new_type} + if isinstance(result["anyOf"], list): + if new_type_dict not in result["anyOf"]: + result["anyOf"].append(new_type_dict) + elif new_type_dict != result["anyOf"]: + result["anyOf"] = [result["anyOf"], new_type_dict] return result if "oneOf" in result: diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py index 28ce1c3fd..5868d3016 100644 --- a/tests/core/test_jsonschema_helpers.py +++ b/tests/core/test_jsonschema_helpers.py @@ -29,6 +29,7 @@ from singer_sdk.typing import ( DEFAULT_JSONSCHEMA_VALIDATOR, AllOf, + AnyOf, AnyType, ArrayType, BooleanType, @@ -49,6 +50,7 @@ IRIType, JSONPointerType, ObjectType, + OneOf, PropertiesList, Property, RegexType, @@ -641,9 +643,10 @@ def test_property_creation( ) -> None: property_dict = property_obj.to_dict() assert property_dict == expected_jsonschema + property_name = next(iter(property_dict.keys())) + property_node = property_dict[property_name] + DEFAULT_JSONSCHEMA_VALIDATOR.check_schema(property_node) for check_fn in TYPE_FN_CHECKS: - property_name = next(iter(property_dict.keys())) - property_node = property_dict[property_name] if check_fn in type_fn_checks_true: assert check_fn(property_node) is True, ( f"{check_fn.__name__} was not True for {property_dict!r}" @@ -654,6 +657,77 @@ def test_property_creation( ) +@pytest.mark.parametrize( + "property_obj,expected_jsonschema", + [ + pytest.param( + Property( + "my_prop", + AnyOf(StringType, ArrayType(StringType)), + required=True, + ), + { + "my_prop": { + "anyOf": [ + {"type": ["string"]}, + {"type": "array", "items": {"type": ["string"]}}, + ], + }, + }, + id="anyof_required", + ), + pytest.param( + Property( + "my_prop", + AnyOf(StringType, ArrayType(StringType)), + required=False, + ), + { + "my_prop": { + "anyOf": [ + {"type": ["string"]}, + {"type": "array", "items": {"type": ["string"]}}, + {"type": "null"}, + ], + }, + }, + id="anyof_optional", + ), + pytest.param( + Property( + "my_prop", + OneOf(StringType, IntegerType), + required=False, + ), + { + "my_prop": { + "oneOf": [ + {"type": ["string"]}, + {"type": ["integer"]}, + {"type": "null"}, + ], + }, + }, + id="oneof_optional", + ), + ], +) +def test_optional_composite_type_produces_valid_schema( + property_obj: Property, + expected_jsonschema: dict, +) -> None: + """Optional `AnyOf`/`OneOf` properties must stay valid JSON Schema. + + Regression test: `append_type` used to splice the bare string "null" into + an existing `anyOf`/`oneOf` list instead of a `{"type": "null"}` subschema, + which fails JSON Schema meta-validation. + """ + property_dict = property_obj.to_dict() + assert property_dict == expected_jsonschema + property_name = next(iter(property_dict.keys())) + DEFAULT_JSONSCHEMA_VALIDATOR.check_schema(property_dict[property_name]) + + def test_wrapped_type_dict(): with pytest.raises( ValueError, diff --git a/tests/core/test_typing.py b/tests/core/test_typing.py index eaf4da9b2..6bd4c6219 100644 --- a/tests/core/test_typing.py +++ b/tests/core/test_typing.py @@ -417,7 +417,7 @@ def test_conform_primitives(value: t.Any, type_dict: dict, expected: t.Any): pytest.param({"type": "array"}, {"type": ["array", "null"]}, id="array"), pytest.param( {"anyOf": [{"type": "integer"}, {"type": "number"}]}, - {"anyOf": [{"type": "integer"}, {"type": "number"}, "null"]}, + {"anyOf": [{"type": "integer"}, {"type": "number"}, {"type": "null"}]}, id="anyOf", ), pytest.param(