Skip to content

Commit 6b4fcf0

Browse files
committed
feat: add subquery support for WHERE col IN (SELECT ...)
- Parser: detect SELECT inside IN() parentheses and recursively parse - Parser: paren-depth tracking for nested subqueries (e.g., inner IN) - Parser: param binding skips subquery conditions (no bind param consumption) - Executor: _resolve_subqueries() pre-executes inner queries once before filtering - SQL_SPEC.md: document partial subquery support with constraints - Tests: 7 new tests (parser + executor subquery scenarios)
1 parent 0270144 commit 6b4fcf0

5 files changed

Lines changed: 149 additions & 18 deletions

File tree

docs/SQL_SPEC.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ database engine.
2828
The following SQL features are **rejected at parse time** with `ValueError`:
2929

3030
- `JOIN` (any variant: INNER, LEFT, RIGHT, CROSS, NATURAL)
31-
- Subqueries (scalar, correlated, or in FROM)
31+
- Subqueries except `WHERE col IN (SELECT single_col FROM table [WHERE ...])`
3232
- Common Table Expressions (CTEs / `WITH`)
3333
- `UNION` / `INTERSECT` / `EXCEPT`
3434
- Window functions (`OVER`, `PARTITION BY`)
@@ -299,6 +299,7 @@ DELETE FROM table [WHERE conditions]
299299
| `IS NULL` | `WHERE name IS NULL` | NULL check |
300300
| `IS NOT NULL` | `WHERE name IS NOT NULL` | Non-NULL check |
301301
| `IN` | `WHERE name IN ('Alice', 'Bob')` | Set membership |
302+
| `IN` subquery | `WHERE id IN (SELECT id FROM admins WHERE role = 'admin')` | Set membership from subquery |
302303
| `BETWEEN` | `WHERE score BETWEEN 70 AND 90` | Inclusive range |
303304
| `LIKE` | `WHERE name LIKE 'A%'` | Pattern matching |
304305

@@ -316,6 +317,9 @@ Example: `WHERE name LIKE 'A%'` matches "Alice", "Ann", "A".
316317
- Values must be parenthesized: `IN (1, 2, 3)` or `IN ('a', 'b')`
317318
- Empty IN list raises `ValueError`
318319
- Supports placeholder binding: `IN (?, ?, ?)`
320+
- Supports single-column subqueries: `IN (SELECT id FROM admins)`
321+
- Subquery form supports optional inner WHERE: `IN (SELECT id FROM admins WHERE role = 'admin')`
322+
- Subquery limits: single-column SELECT only, non-correlated only, no scalar subqueries, no FROM-subqueries
319323

320324
#### 7.2.3 BETWEEN Clause
321325

@@ -334,7 +338,7 @@ Example: `WHERE name LIKE 'A%'` matches "Alice", "Ann", "A".
334338
- `a OR b AND c` = `a OR (b AND c)`
335339
- `NOT` operator is **not supported**.
336340
- Parenthesized expressions in WHERE (e.g., `WHERE (x = 1)`) are **not supported**
337-
and raise `ValueError`. Exception: parentheses in `IN (...)` are allowed.
341+
and raise `ValueError`. Exceptions: parentheses in `IN (...)` literal lists and `IN (SELECT ...)` are allowed.
338342

339343
### 7.4 Type Coercion
340344

@@ -454,9 +458,12 @@ where_expr = condition { ("AND" | "OR") condition } ;
454458
condition = column operator value
455459
| column "IS" [ "NOT" ] "NULL"
456460
| column "IN" "(" value_list ")"
461+
| column "IN" "(" subquery_select ")"
457462
| column "BETWEEN" value "AND" value
458463
| column "LIKE" pattern ;
459464
465+
subquery_select = "SELECT" column "FROM" table [ "WHERE" where_expr ] ;
466+
460467
operator = "=" | "==" | "!=" | "<>" | ">" | ">=" | "<" | "<=" ;
461468
direction = "ASC" | "DESC" ;
462469
value = string | number | "NULL" | "?" ;

src/excel_dbapi/executor.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ def _execute_select(
240240
columns = parsed["columns"]
241241
where = parsed.get("where")
242242
if where:
243+
self._resolve_subqueries(where)
243244
rows = [row for row in rows if self._matches_where(row, where)]
244245

245246
group_by: list[str] | None = parsed.get("group_by")
@@ -307,6 +308,13 @@ def _execute_select(
307308
lastrowid=None,
308309
)
309310

311+
def _resolve_subqueries(self, where: dict[str, Any]) -> None:
312+
for condition in where.get("conditions", []):
313+
value = condition.get("value")
314+
if isinstance(value, dict) and value.get("type") == "subquery":
315+
subquery_result = self.execute(value["query"])
316+
condition["value"] = tuple(row[0] for row in subquery_result.rows if row)
317+
310318
def _execute_aggregate_select(
311319
self,
312320
action: str,

src/excel_dbapi/parser.py

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@ def _values_to_bind_from_condition(condition: Dict[str, Any]) -> List[Any]:
229229
if operator in {"IS", "IS NOT"}:
230230
return []
231231
if operator == "IN":
232-
return list(condition["value"])
232+
value = condition["value"]
233+
if isinstance(value, dict) and value.get("type") == "subquery":
234+
return []
235+
return list(value)
233236
if operator == "BETWEEN":
234237
low, high = condition["value"]
235238
return [low, high]
@@ -243,7 +246,10 @@ def _apply_bound_values_to_condition(
243246
if operator in {"IS", "IS NOT"}:
244247
return 0
245248
if operator == "IN":
246-
size = len(condition["value"])
249+
value = condition["value"]
250+
if isinstance(value, dict) and value.get("type") == "subquery":
251+
return 0
252+
size = len(value)
247253
condition["value"] = tuple(bound_values[offset : offset + size])
248254
return size
249255
if operator == "BETWEEN":
@@ -344,7 +350,19 @@ def _parse_where_expression(
344350
raise ValueError("Invalid WHERE clause format")
345351
in_start = index + 2
346352
in_end = in_start
347-
while in_end < len(tokens) and ")" not in tokens[in_end]:
353+
paren_depth = 0
354+
while in_end < len(tokens):
355+
token = tokens[in_end]
356+
if token == "(":
357+
paren_depth += 1
358+
elif token == ")":
359+
if paren_depth == 0:
360+
raise ValueError(
361+
"Invalid WHERE clause format: malformed IN clause"
362+
)
363+
paren_depth -= 1
364+
if paren_depth == 0:
365+
break
348366
in_end += 1
349367
if in_end >= len(tokens):
350368
raise ValueError(
@@ -361,21 +379,32 @@ def _parse_where_expression(
361379
"Invalid WHERE clause format: IN clause cannot be empty"
362380
)
363381

364-
parsed_values = tuple(
365-
_parse_value(token) for token in _split_csv(raw_values)
366-
)
367-
if len(parsed_values) == 0:
368-
raise ValueError(
369-
"Invalid WHERE clause format: IN clause cannot be empty"
382+
raw_tokens = raw_values.split()
383+
if raw_tokens and raw_tokens[0].upper() == "SELECT":
384+
subquery_parsed = _parse_select(raw_values, params=None)
385+
conditions.append(
386+
{
387+
"column": column,
388+
"operator": "IN",
389+
"value": {"type": "subquery", "query": subquery_parsed},
390+
}
370391
)
392+
else:
393+
parsed_values = tuple(
394+
_parse_value(token) for token in _split_csv(raw_values)
395+
)
396+
if len(parsed_values) == 0:
397+
raise ValueError(
398+
"Invalid WHERE clause format: IN clause cannot be empty"
399+
)
371400

372-
conditions.append(
373-
{
374-
"column": column,
375-
"operator": "IN",
376-
"value": parsed_values,
377-
}
378-
)
401+
conditions.append(
402+
{
403+
"column": column,
404+
"operator": "IN",
405+
"value": parsed_values,
406+
}
407+
)
379408
index = in_end + 1
380409
elif operator == "LIKE":
381410
if index + 2 >= len(tokens):

tests/test_executor.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@ def _create_users_workbook(path: Path) -> None:
6262
workbook.save(path)
6363

6464

65+
def _create_users_admins_workbook(path: Path) -> None:
66+
workbook = Workbook()
67+
68+
users = workbook.active
69+
assert users is not None
70+
users.title = "users"
71+
users.append(["id", "name"])
72+
users.append([1, "Alice"])
73+
users.append([2, "Bob"])
74+
users.append([3, "Charlie"])
75+
76+
admins = workbook.create_sheet("admins")
77+
admins.append(["id", "role"])
78+
admins.append([1, "admin"])
79+
admins.append([3, "editor"])
80+
81+
workbook.save(path)
82+
83+
6584
def test_executor_distinct_removes_duplicates_and_preserves_order(tmp_path: Path):
6685
file_path = tmp_path / "distinct_order.xlsx"
6786
_create_select_workbook(file_path)
@@ -305,3 +324,40 @@ def test_order_by_rejects_aggregate_with_expression_arg(tmp_path: Path):
305324

306325
with pytest.raises(ValueError, match="Unsupported aggregate expression"):
307326
SharedExecutor(engine).execute(parsed)
327+
328+
329+
def test_subquery_in_where(tmp_path: Path):
330+
file_path = tmp_path / "subquery_in_where.xlsx"
331+
_create_users_admins_workbook(file_path)
332+
333+
engine = OpenpyxlBackend(str(file_path))
334+
parsed = parse_sql("SELECT id, name FROM users WHERE id IN (SELECT id FROM admins)")
335+
results = SharedExecutor(engine).execute(parsed)
336+
337+
assert results.rows == [(1, "Alice"), (3, "Charlie")]
338+
339+
340+
def test_subquery_returns_empty(tmp_path: Path):
341+
file_path = tmp_path / "subquery_empty.xlsx"
342+
_create_users_admins_workbook(file_path)
343+
344+
engine = OpenpyxlBackend(str(file_path))
345+
parsed = parse_sql(
346+
"SELECT id, name FROM users WHERE id IN (SELECT id FROM admins WHERE role = 'root')"
347+
)
348+
results = SharedExecutor(engine).execute(parsed)
349+
350+
assert results.rows == []
351+
352+
353+
def test_subquery_with_where(tmp_path: Path):
354+
file_path = tmp_path / "subquery_with_where.xlsx"
355+
_create_users_admins_workbook(file_path)
356+
357+
engine = OpenpyxlBackend(str(file_path))
358+
parsed = parse_sql(
359+
"SELECT id, name FROM users WHERE id IN (SELECT id FROM admins WHERE role = 'admin')"
360+
)
361+
results = SharedExecutor(engine).execute(parsed)
362+
363+
assert results.rows == [(1, "Alice")]

tests/test_parser.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,37 @@ def test_parse_select_with_order_limit_and_conditions():
8080
assert parsed["where"]["conditions"][0]["operator"] == ">="
8181

8282

83+
def test_parse_subquery_in_where():
84+
result = parse_sql("SELECT * FROM users WHERE id IN (SELECT id FROM admins)")
85+
condition = result["where"]["conditions"][0]
86+
assert condition["operator"] == "IN"
87+
assert condition["value"]["type"] == "subquery"
88+
assert condition["value"]["query"]["action"] == "SELECT"
89+
assert condition["value"]["query"]["table"] == "admins"
90+
91+
92+
def test_parse_subquery_with_where():
93+
result = parse_sql(
94+
"SELECT * FROM users WHERE id IN (SELECT id FROM admins WHERE role = 'admin')"
95+
)
96+
subquery = result["where"]["conditions"][0]["value"]["query"]
97+
assert subquery["where"] is not None
98+
99+
100+
def test_parse_subquery_with_nested_in_parentheses():
101+
result = parse_sql(
102+
"SELECT * FROM users WHERE id IN (SELECT id FROM admins WHERE level IN (1, 2))"
103+
)
104+
subquery = result["where"]["conditions"][0]["value"]["query"]
105+
assert subquery["where"] is not None
106+
107+
108+
def test_parse_subquery_preserves_literal_in():
109+
result = parse_sql("SELECT * FROM users WHERE id IN (1, 2, 3)")
110+
condition = result["where"]["conditions"][0]
111+
assert condition["value"] == (1, 2, 3)
112+
113+
83114
def test_parse_update_with_or_where():
84115
parsed = parse_sql("UPDATE Sheet1 SET name = 'A' WHERE id = 1 OR id = 2")
85116
assert parsed["where"]["conjunctions"] == ["OR"]

0 commit comments

Comments
 (0)