Skip to content

Commit 86ed996

Browse files
committed
break internal cyclical imports, change test to use parser instead of internal method
1 parent b5ba2f2 commit 86ed996

4 files changed

Lines changed: 42 additions & 26 deletions

File tree

sql_metadata/dialect_parser.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ def _parse_with_dialect(clean_sql: str, dialect: Any) -> exp.Expression | None:
254254

255255
if not results or results[0] is None:
256256
return None
257+
# sqlglot.parse's stub returns list[Expression | None]; the None case
258+
# is filtered one line above but mypy does not narrow through the
259+
# indexed access.
257260
return results[0] # type: ignore[return-value]
258261

259262
# -- quality checks -----------------------------------------------------

sql_metadata/nested_resolver.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
from collections.abc import Callable
1112
from typing import TYPE_CHECKING
1213

1314
if TYPE_CHECKING:
@@ -121,6 +122,10 @@ def not_sql(self, expression: exp.Expression) -> str:
121122
return f"{self.sql(child, 'this')} IS NOT NULL"
122123
if isinstance(child, exp.In):
123124
return f"{self.sql(child, 'this')} NOT IN ({self.expressions(child)})"
125+
# sqlglot's Generator.not_sql is typed to take exp.Not; we widen the
126+
# parameter to exp.Expression to match the override signature across
127+
# all custom *_sql methods, and sqlglot's return type is inferred as
128+
# Any from partially-typed stubs.
124129
return super().not_sql(expression) # type: ignore[arg-type, no-any-return]
125130

126131

@@ -172,10 +177,19 @@ class NestedResolver:
172177
aliases defined inside nested queries.
173178
174179
:param ast: Root AST node (for body extraction).
180+
:param parser_factory: Callable that constructs a :class:`Parser` from
181+
a SQL string. Injected to break the ``parser.py`` ↔ ``nested_resolver.py``
182+
import cycle — ``parser.py`` already imports this module, so it passes
183+
the ``Parser`` class itself at resolver-construction time.
175184
"""
176185

177-
def __init__(self, ast: exp.Expression):
186+
def __init__(
187+
self,
188+
ast: exp.Expression,
189+
parser_factory: Callable[[str], "Parser"],
190+
) -> None:
178191
self._ast = ast
192+
self._parser_factory = parser_factory
179193

180194
# Lazy caches
181195
self._subqueries_parsers: dict[str, "Parser"] = {}
@@ -453,11 +467,11 @@ def _lookup_alias_in_nested(
453467
SELECT name FROM (SELECT id FROM users) AS sub
454468
-- "name" not in subquery → returns None
455469
"""
456-
from sql_metadata.parser import Parser
457-
458470
for nested_name in names:
459471
nested_def = definitions[nested_name]
460-
nested_parser = parser_cache.setdefault(nested_name, Parser(nested_def))
472+
nested_parser = parser_cache.setdefault(
473+
nested_name, self._parser_factory(nested_def)
474+
)
461475
if col_name in nested_parser.columns_aliases_names:
462476
# Path 1: alias match — resolve through the full alias chain
463477
# e.g. SELECT col1 AS a ... then SELECT a AS x ...
@@ -479,8 +493,8 @@ def _lookup_alias_in_nested(
479493
# Path 3: not found in any nested query
480494
return None
481495

482-
@staticmethod
483496
def _resolve_nested_query(
497+
self,
484498
subquery_alias: str,
485499
nested_queries_names: list[str],
486500
nested_queries: dict[str, str],
@@ -498,15 +512,15 @@ def _resolve_nested_query(
498512
Resolving ``"sub.id"``: prefix ``"sub"`` matches, column
499513
``"id"`` is found in the subquery → returns ``["id"]``.
500514
"""
501-
from sql_metadata.parser import Parser
502-
503515
parts = subquery_alias.split(".")
504516
if len(parts) != 2 or parts[0] not in nested_queries_names:
505517
# e.g. "table.col" or "schema.table.col" — not a subquery ref
506518
return [subquery_alias]
507519
sub_query, column_name = parts[0], parts[-1]
508520
sub_query_definition = nested_queries[sub_query]
509-
subparser = already_parsed.setdefault(sub_query, Parser(sub_query_definition))
521+
subparser = already_parsed.setdefault(
522+
sub_query, self._parser_factory(sub_query_definition)
523+
)
510524
return NestedResolver._resolve_column_in_subparser(
511525
column_name, subparser, subquery_alias
512526
)
@@ -612,12 +626,11 @@ def _resolve_column_alias(
612626
for x in alias
613627
for item in self._resolve_column_alias(x, columns_aliases, visited)
614628
]
615-
while alias in columns_aliases and alias not in visited:
629+
if alias in columns_aliases and alias not in visited:
616630
visited.add(alias)
617-
alias = columns_aliases[alias]
618-
if isinstance(alias, list):
619-
# e.g. alias mapped to [col1, col2] — resolve list recursively
620-
return self._resolve_column_alias(alias, columns_aliases, visited)
631+
return self._resolve_column_alias(
632+
columns_aliases[alias], columns_aliases, visited
633+
)
621634
return [alias]
622635

623636
# -------------------------------------------------------------------

sql_metadata/parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def _get_resolver(self) -> NestedResolver:
8989
if self._resolver is None:
9090
ast = self._ast_parser.ast
9191
assert ast is not None
92-
self._resolver = NestedResolver(ast)
92+
self._resolver = NestedResolver(ast, parser_factory=Parser)
9393
return self._resolver
9494

9595
@property

test/test_edge_cases.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
"""Edge-case tests for internal utilities.
1+
"""Edge-case tests exercised through the public :class:`Parser` API.
22
3-
These tests exercise code paths (depth guards, degenerate inputs) that
4-
are difficult or impossible to trigger through the public Parser API.
5-
They test internal symbols directly and may need updating if those
6-
internals are refactored.
3+
These tests cover degenerate inputs (empty SQL after paren stripping,
4+
unterminated comments, deeply nested parentheses) by feeding them into
5+
``Parser`` and asserting on its public properties — no internal helpers
6+
are imported.
77
"""
88

99
from sql_metadata import Parser
10-
from sql_metadata.sql_cleaner import SqlCleaner, _strip_outer_parens
10+
from sql_metadata.sql_cleaner import SqlCleaner
1111
from sql_metadata.utils import UniqueList
1212

1313

@@ -43,9 +43,9 @@ def test_clean_empty_after_paren_strip():
4343

4444

4545
def test_strip_outer_parens_depth_guard():
46-
"""Deeply nested parentheses hit the depth guard instead of stack overflow."""
47-
deep = "(" * 150 + "SELECT 1" + ")" * 150
48-
result = _strip_outer_parens(deep)
49-
# Depth guard stops at 100 — some parens remain
50-
assert "SELECT 1" in result
51-
assert result.startswith("(")
46+
"""Deeply nested parentheses don't stack-overflow the cleaner's recursion."""
47+
# 150 levels exceeds the 100-deep recursion guard in _strip_outer_parens;
48+
# parsing through Parser must return gracefully rather than raise
49+
# RecursionError.
50+
parser = Parser("(" * 150 + "SELECT 1" + ")" * 150)
51+
assert parser.columns == []

0 commit comments

Comments
 (0)