Repository: meltano/sdk
Pull request #3009 (2025-05-01 04:52:34)

fix: Fix infinite recursion error when resolving JSON schemas with circular references
## Links

- https://json-schema.org/understanding-json-schema/structuring#recursion

## Summary by Sourcery

Enhance schema reference resolution to handle circular references in JSON schemas

Bug Fixes:
- Prevent infinite recursion when resolving schemas with circular references

Enhancements:
- Add support for tracking visited references during schema resolution

Tests:
- Add test case for resolving schemas with circular references

<!-- readthedocs-preview meltano-sdk start -->
----
📚 Documentation preview 📚: https://meltano-sdk--3009.org.readthedocs.build/en/3009/

<!-- readthedocs-preview meltano-sdk end -->

## Summary by Sourcery

Enhance schema reference resolution to handle circular references in JSON schemas

Bug Fixes:
- Prevent infinite recursion when resolving schemas with circular references

Enhancements:
- Add support for tracking visited references during schema resolution

Tests:
- Add test case for resolving schemas with circular references

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!

#### 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 -->
codspeed-hq[bot]: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/meltano/sdk/branches/edgarrmondragon%2Ffix%2Fdo-not-crash-resolve-recursive-schemas)

### Merging #3009 will **not alter performance**

<sub>Comparing <code>edgarrmondragon/fix/do-not-crash-resolve-recursive-schemas</code> (ab740dd) with <code>main</code> (36be25d)</sub>



### Summary

`✅ 8` untouched benchmarks  





Patch:
diff --git a/singer_sdk/about.py b/singer_sdk/about.py
index ac1e76fa..04a1875b 100644
--- a/singer_sdk/about.py
+++ b/singer_sdk/about.py
@@ -6,7 +6,6 @@ import abc
 import dataclasses
 import json
 import typing as t
-from collections import OrderedDict
 from textwrap import dedent
 
 from packaging.specifiers import SpecifierSet
@@ -164,17 +163,15 @@ class JSONFormatter(AboutFormatter, format_name="json"):
         Returns:
             A formatted string.
         """
-        data = OrderedDict(
-            [
-                ("name", about_info.name),
-                ("description", about_info.description),
-                ("version", about_info.version),
-                ("sdk_version", about_info.sdk_version),
-                ("supported_python_versions", about_info.supported_python_versions),
-                ("capabilities", [c.value for c in about_info.capabilities]),
-                ("settings", about_info.settings),
-            ],
-        )
+        data = {
+            "name": about_info.name,
+            "description": about_info.description,
+            "version": about_info.version,
+            "sdk_version": about_info.sdk_version,
+            "supported_python_versions": about_info.supported_python_versions,
+            "capabilities": [c.value for c in about_info.capabilities],
+            "settings": about_info.settings,
+        }
         return json.dumps(data, indent=self.indent, default=self.default)
 
 
diff --git a/singer_sdk/singerlib/schema.py b/singer_sdk/singerlib/schema.py
index 0ed1e03e..035851fe 100644
--- a/singer_sdk/singerlib/schema.py
+++ b/singer_sdk/singerlib/schema.py
@@ -251,43 +251,82 @@ def resolve_schema_references(
     return _resolve_schema_references(schema, resolver)
 
 
-def _resolve_schema_references(  # noqa: C901
+def _resolve_schema_references(  # noqa: C901, PLR0912
     schema: dict[str, t.Any],
     resolver: Resolver,
+    visited_refs: set[str] | None = None,
 ) -> dict[str, t.Any]:
+    """Recursively resolve schema references while handling circular references.
+
+    Args:
+        schema: The schema dict to resolve references in
+        resolver: The JSON Schema resolver to use
+        visited_refs: Set of already visited reference paths to prevent infinite
+            recursion
+
+    Returns:
+        The schema with all references resolved
+    """
+    if visited_refs is None:
+        visited_refs = set()
+
     if _SchemaKey.ref in schema:
         reference_path = schema.pop(_SchemaKey.ref, None)
+        if reference_path in visited_refs:
+            # We've already seen this reference, return the schema as-is
+            # to prevent infinite recursion
+            return schema
+
+        visited_refs.add(reference_path)
         resolved = resolver.lookup(reference_path)
         schema.update(resolved.contents)
-        return _resolve_schema_references(schema, resolver)
+        return _resolve_schema_references(schema, resolver, visited_refs)
 
     if _SchemaKey.properties in schema:
         for k, val in schema[_SchemaKey.properties].items():
-            schema[_SchemaKey.properties][k] = _resolve_schema_references(val, resolver)
+            schema[_SchemaKey.properties][k] = _resolve_schema_references(
+                val,
+                resolver,
+                visited_refs,
+            )
 
     if _SchemaKey.pattern_properties in schema:
         for k, val in schema[_SchemaKey.pattern_properties].items():
             schema[_SchemaKey.pattern_properties][k] = _resolve_schema_references(
                 val,
                 resolver,
+                visited_refs,
             )
 
     if _SchemaKey.items in schema:
         schema[_SchemaKey.items] = _resolve_schema_references(
             schema[_SchemaKey.items],
             resolver,
+            visited_refs,
         )
 
     if _SchemaKey.any_of in schema:
         for i, element in enumerate(schema[_SchemaKey.any_of]):
-            schema[_SchemaKey.any_of][i] = _resolve_schema_references(element, resolver)
+            schema[_SchemaKey.any_of][i] = _resolve_schema_references(
+                element,
+                resolver,
+                visited_refs,
+            )
 
     if _SchemaKey.all_of in schema:
         for i, element in enumerate(schema[_SchemaKey.all_of]):
-            schema[_SchemaKey.all_of][i] = _resolve_schema_references(element, resolver)
+            schema[_SchemaKey.all_of][i] = _resolve_schema_references(
+                element,
+                resolver,
+                visited_refs,
+            )
 
     if _SchemaKey.one_of in schema:
         for i, element in enumerate(schema[_SchemaKey.one_of]):
-            schema[_SchemaKey.one_of][i] = _resolve_schema_references(element, resolver)
+            schema[_SchemaKey.one_of][i] = _resolve_schema_references(
+                element,
+                resolver,
+                visited_refs,
+            )
 
     return schema


Test patch:
diff --git a/tests/singerlib/test_schema.py b/tests/singerlib/test_schema.py
index 24ec98d8..89c22d8d 100644
--- a/tests/singerlib/test_schema.py
+++ b/tests/singerlib/test_schema.py
@@ -365,6 +365,56 @@ def test_schema_from_dict(pydict, expected):
             },
             id="resolve_nested_one_of",
         ),
+        pytest.param(
+            {
+                "type": "object",
+                "properties": {
+                    "filter": {
+                        "$ref": "components#/schemas/Filter",
+                    },
+                },
+            },
+            {
+                "components": {
+                    "schemas": {
+                        "Filter": {
+                            "properties": {
+                                "name": {
+                                    "type": "string",
+                                    "title": "Name",
+                                },
+                                "clauses": {
+                                    "type": "array",
+                                    "items": {
+                                        "$ref": "components#/schemas/Filter",
+                                    },
+                                    "title": "Clauses",
+                                },
+                            },
+                        },
+                    },
+                },
+            },
+            {
+                "type": "object",
+                "properties": {
+                    "filter": {
+                        "properties": {
+                            "name": {
+                                "type": "string",
+                                "title": "Name",
+                            },
+                            "clauses": {
+                                "type": "array",
+                                "items": {},
+                                "title": "Clauses",
+                            },
+                        },
+                    },
+                },
+            },
+            id="resolve_schema_references_with_circular_references",
+        ),
     ],
 )
 def test_resolve_schema_references(schema, refs, expected):