Repository: meltano/sdk
Pull request #2973 (2025-04-15 00:30:16)

fix: Recognize `oneOf` fields in Singer schema helpers
## Summary by Sourcery

Enhance Singer schema helpers to recognize and handle `oneOf` fields more comprehensively, including support for nested `oneOf` schemas and additional schema properties.

New Features:
- Add support for nested `oneOf` schemas in JSON schema validation and type checking
- Introduce handling of `oneOf` fields in schema resolution and type detection

Enhancements:
- Extend schema parsing to support additional JSON schema properties like `deprecated`
- Improve schema reference resolution for complex `oneOf` schemas

Tests:
- Add test cases for resolving complex `oneOf` schemas with nested references
- Expand test coverage for date and datetime type detection with nested `oneOf` schemas

sourcery-ai[bot]: <!-- Generated by sourcery-ai[bot]: start review_guide -->

🧙 Sourcery is reviewing your pull request!

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/meltano/sdk/pull/2973?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 91.53%. Comparing base [(`b761e02`)](https://app.codecov.io/gh/meltano/sdk/commit/b761e02d6a5272035c213037d2e59278b6662509?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) to head [(`102ddb0`)](https://app.codecov.io/gh/meltano/sdk/commit/102ddb070b49356ac367f5a410c7c7069bd086b2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #2973   +/-   ##
=======================================
  Coverage   91.53%   91.53%           
=======================================
  Files          63       63           
  Lines        5315     5317    +2     
  Branches      683      683           
=======================================
+ Hits         4865     4867    +2     
  Misses        318      318           
  Partials      132      132           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/meltano/sdk/pull/2973?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
codspeed-hq[bot]: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/meltano/sdk/branches/edgarrmondragon%2Ffix%2Fsingerlib-schema-recognize-oneof)

### Merging #2973 will **not alter performance**

<sub>Comparing <code>edgarrmondragon/fix/singerlib-schema-recognize-oneof</code> (102ddb0) with <code>main</code> (b761e02)</sub>



### Summary

`✅ 7` untouched benchmarks  





Patch:
diff --git a/singer_sdk/singerlib/schema.py b/singer_sdk/singerlib/schema.py
index 5ebe9e8a..0ed1e03e 100644
--- a/singer_sdk/singerlib/schema.py
+++ b/singer_sdk/singerlib/schema.py
@@ -36,12 +36,14 @@ STANDARD_KEYS = [
     "pattern",
     "contentMediaType",
     "contentEncoding",
+    "deprecated",
     # These are NOT simple keys (they can contain schemas themselves). We could
     # consider adding extra handling to them.
     "additionalProperties",
     "anyOf",
     "patternProperties",
     "allOf",
+    "oneOf",
     # JSON Schema extensions
     "x-sql-datatype",
 ]
@@ -85,10 +87,15 @@ class Schema:
     pattern: str | None = None
     contentMediaType: str | None = None  # noqa: N815
     contentEncoding: str | None = None  # noqa: N815
-
     # JSON Schema extensions
     x_sql_datatype: str | None = None
 
+    deprecated: bool | None = None
+    oneOf: t.Any | None = None  # noqa: N815
+
+    # TODO: Use dataclass.KW_ONLY when Python 3.9 support is dropped
+    # _: KW_ONLY  # noqa: ERA001
+
     def to_dict(self) -> dict[str, t.Any]:
         """Return the raw JSON Schema as a (possibly nested) dict.
 
@@ -151,10 +158,22 @@ class Schema:
             ...             "minimum": 0,
             ...             "x-sql-datatype": "smallint",
             ...         },
+            ...         "deprecatedField": {
+            ...             "type": "string",
+            ...             "deprecated": True,
+            ...         },
+            ...         "price": {
+            ...             "oneOf": [
+            ...                 {"type": "number", "deprecated": True},
+            ...                 {"type": "null"},
+            ...             ],
+            ...         },
             ...     },
             ...     "required": ["firstName", "lastName"],
             ... }
             >>> schema = Schema.from_dict(data)
+            >>> schema.schema
+            'http://json-schema.org/draft/2020-12/schema'
             >>> schema.title
             'Person'
             >>> schema.properties["firstName"].description
@@ -163,8 +182,10 @@ class Schema:
             0
             >>> schema.properties["age"].x_sql_datatype
             'smallint'
-            >>> schema.schema
-            'http://json-schema.org/draft/2020-12/schema'
+            >>> schema.properties["deprecatedField"].deprecated
+            True
+            >>> schema.properties["price"].oneOf[0]["deprecated"]
+            True
         """  # noqa: E501
         kwargs = schema_defaults.copy()
         properties = data.get("properties")


Test patch:
diff --git a/singer_sdk/testing/tap_tests.py b/singer_sdk/testing/tap_tests.py
index e81abc6e..6eaa56f0 100644
--- a/singer_sdk/testing/tap_tests.py
+++ b/singer_sdk/testing/tap_tests.py
@@ -243,7 +243,7 @@ class AttributeIsDateTimeTest(AttributeTestTemplate):
         Returns:
             True if this test is applicable, False if not.
         """
-        return bool(th.is_date_or_datetime_type(property_schema))
+        return th.is_date_or_datetime_type(property_schema)
 
 
 class AttributeIsBooleanTest(AttributeTestTemplate):
diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py
index cb960826..5957d5ab 100644
--- a/tests/core/test_jsonschema_helpers.py
+++ b/tests/core/test_jsonschema_helpers.py
@@ -1125,6 +1125,21 @@ def test_is_datetime_type(schema, expected):
             True,
             id="oneof_datetime",
         ),
+        pytest.param(
+            {
+                "oneOf": [
+                    {"type": "null"},
+                    {
+                        "oneOf": [
+                            {"type": "string", "format": "date-time"},
+                            {"type": "string"},
+                        ],
+                    },
+                ],
+            },
+            True,
+            id="nested_oneof_datetime",
+        ),
     ],
 )
 def test_is_date_or_datetime_type(schema, expected):
diff --git a/tests/core/test_mapper.py b/tests/core/test_mapper.py
index d88545c5..7bb0a37b 100644
--- a/tests/core/test_mapper.py
+++ b/tests/core/test_mapper.py
@@ -22,7 +22,6 @@ from singer_sdk.tap_base import Tap
 from singer_sdk.typing import (
     ArrayType,
     BooleanType,
-    CustomType,
     DateTimeType,
     IntegerType,
     NumberType,
@@ -422,7 +421,7 @@ def wildcard_schemas():
                 ArrayType(
                     ObjectType(
                         Property("id", IntegerType),
-                        Property("value", CustomType({})),
+                        Property("value", OneOf(StringType, IntegerType, BooleanType)),
                     ),
                 ),
             ),
diff --git a/tests/singerlib/test_schema.py b/tests/singerlib/test_schema.py
index 766a60b3..24ec98d8 100644
--- a/tests/singerlib/test_schema.py
+++ b/tests/singerlib/test_schema.py
@@ -269,23 +269,102 @@ def test_schema_from_dict(pydict, expected):
                     {"$ref": "references.json#/definitions/first_type"},
                     {"$ref": "references.json#/definitions/second_type"},
                 ],
+                "title": "A Title",
             },
             {
                 "references.json": {
                     "definitions": {
-                        "first_type": {"type": "string"},
-                        "second_type": {"type": "integer"},
+                        "first_type": {"type": "string", "title": "First Type"},
+                        "second_type": {"type": "integer", "title": "Second Type"},
                     }
                 },
             },
             {
                 "oneOf": [
-                    {"type": "string"},
-                    {"type": "integer"},
-                ]
+                    {"type": "string", "title": "First Type"},
+                    {"type": "integer", "title": "Second Type"},
+                ],
+                "title": "A Title",
             },
             id="resolve_schema_one_of",
         ),
+        pytest.param(
+            {
+                "type": "object",
+                "properties": {
+                    "product_price": {
+                        "oneOf": [
+                            {"$ref": "components#/schemas/LegacyProductPrice"},
+                            {"$ref": "components#/schemas/ProductPrice"},
+                        ],
+                        "title": "Product Price",
+                    },
+                },
+            },
+            {
+                "components": {
+                    "schemas": {
+                        "LegacyProductPrice": {
+                            "type": "object",
+                            "properties": {"price": {"type": "number"}},
+                            "title": "Legacy Product Price",
+                        },
+                        "ProductPrice": {
+                            "oneOf": [
+                                {
+                                    "$ref": "components#/schemas/ProductPriceFixed",
+                                },
+                                {
+                                    "$ref": "components#/schemas/ProductPriceFree",
+                                },
+                            ],
+                        },
+                        "ProductPriceFixed": {
+                            "type": "object",
+                            "properties": {"price": {"type": "number"}},
+                            "title": "Product Price Fixed",
+                        },
+                        "ProductPriceFree": {
+                            "type": "object",
+                            "properties": {"price": {"type": "number", "const": 0}},
+                            "title": "Product Price Free",
+                        },
+                    }
+                },
+            },
+            {
+                "type": "object",
+                "properties": {
+                    "product_price": {
+                        "oneOf": [
+                            {
+                                "type": "object",
+                                "properties": {"price": {"type": "number"}},
+                                "title": "Legacy Product Price",
+                            },
+                            {
+                                "oneOf": [
+                                    {
+                                        "type": "object",
+                                        "properties": {"price": {"type": "number"}},
+                                        "title": "Product Price Fixed",
+                                    },
+                                    {
+                                        "type": "object",
+                                        "properties": {
+                                            "price": {"type": "number", "const": 0}
+                                        },
+                                        "title": "Product Price Free",
+                                    },
+                                ],
+                            },
+                        ],
+                        "title": "Product Price",
+                    },
+                },
+            },
+            id="resolve_nested_one_of",
+        ),
     ],
 )
 def test_resolve_schema_references(schema, refs, expected):