Repository: meltano/sdk (python)

Issue:
bug: `oneOf` schema is not handled by null-appending logic
### Singer SDK Version

0.30.0

### Is this a regression?

- [ ] Yes

### Python Version

NA

### Bug scope

Taps (catalog, state, etc.)

### Operating System

NA

### Description

`oneOf` is not handled by `append_type`, which is used to handle nullable fields:

https://github.com/meltano/sdk/blob/c3a8f90ac4d79eb5c655740d0fd88f393af7f306/singer_sdk/helpers/_typing.py#L52-L74

The solution is probably to do something similar to what's done for `anyOf` types.

Reported [in Slack](https://meltano.slack.com/archives/C01PKLU5D1R/p1690827458305249).

### Code

```python
from singer_sdk.typing import IntegerType, OneOf, Property, StringType

prop = Property("step_number", OneOf(IntegerType, StringType))
prop.to_dict()
```


Pull request description:
fix: Instances of `oneOf` are now handled by null-appending logic
Closes #1887 

<!-- readthedocs-preview meltano-sdk start -->
----
📚 Documentation preview 📚: https://meltano-sdk--2245.org.readthedocs.build/en/2245/

<!-- readthedocs-preview meltano-sdk end -->

Patch:
diff --git a/singer_sdk/helpers/_typing.py b/singer_sdk/helpers/_typing.py
index 6df38e83..f3ea68e1 100644
--- a/singer_sdk/helpers/_typing.py
+++ b/singer_sdk/helpers/_typing.py
@@ -59,6 +59,10 @@ def append_type(type_dict: dict, new_type: str) -> dict:
             result["anyOf"] = [result["anyOf"], new_type]
         return result
 
+    if "oneOf" in result:
+        result["oneOf"].append(new_type)
+        return result
+
     if "type" in result:
         type_array = (
             result["type"] if isinstance(result["type"], list) else [result["type"]]


Test patch:
diff --git a/tests/core/test_typing.py b/tests/core/test_typing.py
index 7bb0ab36..b985aeec 100644
--- a/tests/core/test_typing.py
+++ b/tests/core/test_typing.py
@@ -19,6 +19,7 @@ from singer_sdk.typing import (
     PropertiesList,
     Property,
     StringType,
+    append_type,
     to_sql_type,
 )
 
@@ -318,3 +319,33 @@ def test_conform_primitives():
 )
 def test_to_sql_type(jsonschema_type, expected):
     assert isinstance(to_sql_type(jsonschema_type), expected)
+
+
+@pytest.mark.parametrize(
+    "type_dict,expected",
+    [
+        pytest.param({"type": "string"}, {"type": ["string", "null"]}, id="string"),
+        pytest.param({"type": "integer"}, {"type": ["integer", "null"]}, id="integer"),
+        pytest.param({"type": "number"}, {"type": ["number", "null"]}, id="number"),
+        pytest.param({"type": "boolean"}, {"type": ["boolean", "null"]}, id="boolean"),
+        pytest.param(
+            {"type": "object", "properties": {}},
+            {"type": ["object", "null"], "properties": {}},
+            id="object",
+        ),
+        pytest.param({"type": "array"}, {"type": ["array", "null"]}, id="array"),
+        pytest.param(
+            {"anyOf": [{"type": "integer"}, {"type": "number"}]},
+            {"anyOf": [{"type": "integer"}, {"type": "number"}, "null"]},
+            id="anyOf",
+        ),
+        pytest.param(
+            {"oneOf": [{"type": "integer"}, {"type": "number"}]},
+            {"oneOf": [{"type": "integer"}, {"type": "number"}, "null"]},
+            id="oneOf",
+        ),
+    ],
+)
+def test_append_null(type_dict: dict, expected: dict):
+    result = append_type(type_dict, "null")
+    assert result == expected