Skip to content

Commit 1332b0b

Browse files
committed
Add subquery support
1 parent d104dbd commit 1332b0b

3 files changed

Lines changed: 163 additions & 1 deletion

File tree

pymongosql/sql/handler.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ class ParseResult:
4747
limit_value: Optional[int] = None
4848
offset_value: Optional[int] = None
4949

50+
# Subquery info (for wrapped subqueries, e.g., Superset outering)
51+
subquery_plan: Optional[Any] = None
52+
subquery_alias: Optional[str] = None
53+
5054
# Factory methods for different use cases
5155
@classmethod
5256
def for_visitor(cls) -> "ParseResult":
@@ -952,9 +956,23 @@ def can_handle(self, ctx: Any) -> bool:
952956
"""Check if this is a from context"""
953957
return hasattr(ctx, "tableReference")
954958

959+
# Subquery alias parsing and filter-key rewriting has been delegated to SubqueryResolver.
960+
# See `pymongosql/sql/subquery.py` for the implementation.
961+
pass
962+
955963
def handle_visitor(self, ctx: PartiQLParser.FromClauseContext, parse_result: "ParseResult") -> Any:
956964
if hasattr(ctx, "tableReference") and ctx.tableReference():
957-
collection_name = ctx.tableReference().getText()
965+
table_text = ctx.tableReference().getText()
966+
967+
# Detect wrapped subquery pattern: (SELECT ...) alias
968+
from .subquery import SubqueryResolver
969+
970+
if SubqueryResolver.is_wrapped_subquery_text(table_text):
971+
resolver = SubqueryResolver()
972+
return resolver.resolve_and_apply(table_text, parse_result)
973+
974+
# Default: simple table reference
975+
collection_name = table_text
958976
parse_result.collection = collection_name
959977
return collection_name
960978
return None

pymongosql/sql/subquery.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# -*- coding: utf-8 -*-
2+
"""Subquery resolution and normalization utilities
3+
4+
Encapsulates logic for detecting wrapped subqueries in FROM clauses, parsing
5+
inner SQL into an ExecutionPlan, and applying remapping/merging to an
6+
outer ParseResult.
7+
8+
This keeps FromHandler lightweight and avoids mixing parsing logic in the
9+
main handler module.
10+
"""
11+
import logging
12+
import re
13+
from typing import Any, Optional, Tuple
14+
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
class SubqueryResolver:
19+
"""Resolve and apply FROM subquery expressions.
20+
21+
Public methods:
22+
is_wrapped_subquery_text(text) -> bool
23+
resolve_and_apply(table_text, parse_result) -> collection_name
24+
"""
25+
26+
@staticmethod
27+
def is_wrapped_subquery_text(text: str) -> bool:
28+
t = text.strip()
29+
return t.startswith("(") and "SELECT" in t.upper()
30+
31+
def _strip_alias_and_repair(self, text: str) -> Tuple[str, Optional[str]]:
32+
text = text.strip()
33+
try:
34+
first = text.index("(")
35+
last = text.rfind(")")
36+
if first == -1 or last == -1 or last <= first:
37+
return text, None
38+
inner = text[first + 1 : last].strip()
39+
rest = text[last + 1 :].strip()
40+
if rest.upper().startswith("AS "):
41+
rest = rest[3:].strip()
42+
alias = rest if rest else None
43+
44+
# Repair collapsed inner SQL spacing heuristically so it can be reparsed
45+
repaired = re.sub(
46+
r"(?i)(SELECT|FROM|WHERE|AS|AND|OR|GROUP|ORDER|LIMIT|OFFSET|IN|ON|JOIN|LEFT|RIGHT|INNER|OUTER|HAVING)",
47+
r" \1 ",
48+
inner,
49+
)
50+
repaired = repaired.replace(",", ", ")
51+
repaired = re.sub(r"\s+", " ", repaired).strip()
52+
53+
return repaired, alias
54+
except ValueError:
55+
return text, None
56+
57+
def _rewrite_projection(self, projection: dict, alias: str) -> dict:
58+
new = {}
59+
for k, v in projection.items():
60+
if isinstance(k, str) and k.startswith(alias + "."):
61+
new_key = k[len(alias) + 1 :]
62+
else:
63+
new_key = k
64+
new[new_key] = v
65+
return new
66+
67+
def _rewrite_filter_keys(self, obj: Any, alias: str) -> Any:
68+
if isinstance(obj, dict):
69+
new = {}
70+
for k, v in obj.items():
71+
if isinstance(k, str) and k.startswith(f"{alias}."):
72+
new_key = k[len(alias) + 1 :]
73+
else:
74+
new_key = k
75+
76+
if k in {"$and", "$or", "$nor"} and isinstance(v, list):
77+
new[new_key] = [self._rewrite_filter_keys(i, alias) for i in v]
78+
else:
79+
new[new_key] = self._rewrite_filter_keys(v, alias)
80+
return new
81+
elif isinstance(obj, list):
82+
return [self._rewrite_filter_keys(i, alias) for i in obj]
83+
else:
84+
return obj
85+
86+
def resolve_and_apply(self, table_text: str, parse_result: Any) -> str:
87+
"""If `table_text` is a wrapped subquery, parse it and apply to parse_result.
88+
89+
Returns the collection name to use (alias or inner collection or raw text on failure).
90+
"""
91+
if not self.is_wrapped_subquery_text(table_text):
92+
return table_text
93+
94+
inner_sql, alias = self._strip_alias_and_repair(table_text)
95+
96+
try:
97+
# Import here to avoid circular imports at module import time
98+
from .parser import SQLParser
99+
100+
inner_parser = SQLParser(inner_sql)
101+
inner_plan = inner_parser.get_execution_plan()
102+
103+
# Attach to outer parse result
104+
parse_result.subquery_plan = inner_plan
105+
parse_result.subquery_alias = alias
106+
107+
# Rewrite outer projection and filters that use the alias prefix
108+
if alias:
109+
if hasattr(parse_result, "projection") and parse_result.projection:
110+
parse_result.projection = self._rewrite_projection(parse_result.projection, alias)
111+
112+
if hasattr(parse_result, "filter_conditions") and parse_result.filter_conditions:
113+
parse_result.filter_conditions = self._rewrite_filter_keys(parse_result.filter_conditions, alias)
114+
115+
# Merge inner filters into outer filters
116+
if inner_plan.filter_stage:
117+
if not getattr(parse_result, "filter_conditions", None):
118+
parse_result.filter_conditions = inner_plan.filter_stage
119+
else:
120+
parse_result.filter_conditions = {"$and": [inner_plan.filter_stage, parse_result.filter_conditions]}
121+
122+
# Use inner collection as effective collection
123+
parse_result.collection = inner_plan.collection
124+
return alias or inner_plan.collection
125+
126+
except Exception as e:
127+
_logger.warning(f"Failed to parse subquery in FROM: {e}. Falling back to raw table text")
128+
parse_result.collection = table_text
129+
return table_text

tests/test_sql_parser_general.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,21 @@ def test_select_function_with_aliases(self):
354354
"MAX(age)": 1,
355355
}
356356

357+
def test_superset_wrapped_subquery(self):
358+
"""Support Superset wrapping subquery with alias 'virtual'"""
359+
sql = "SELECT virtual.a, virtual.b FROM (SELECT a, b FROM users WHERE c = 1) virtual"
360+
parser = SQLParser(sql)
361+
362+
assert not parser.has_errors, f"Parser errors: {parser.errors}"
363+
364+
execution_plan = parser.get_execution_plan()
365+
# After unwrapping, collection should be inner collection
366+
assert execution_plan.collection == "users"
367+
# Projections should be remapped to inner fields
368+
assert execution_plan.projection_stage == {"a": 1, "b": 1}
369+
# Inner filter should be preserved
370+
assert execution_plan.filter_stage == {"c": 1}
371+
357372
def test_select_single_field_with_alias(self):
358373
"""Test SELECT with single field and alias"""
359374
sql = "SELECT email AS contact_email FROM customers"

0 commit comments

Comments
 (0)