Skip to content

Commit ad4e292

Browse files
committed
fix: resolve aggregate ORDER BY parsing, HAVING context, and ORDER BY scope issues
1 parent 968acd5 commit ad4e292

5 files changed

Lines changed: 108 additions & 30 deletions

File tree

docs/SQL_SPEC.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,14 @@ See [Section 7: WHERE Clause](#7-where-clause).
147147
|---------|-----------|---------|
148148
| Single column || `ORDER BY name ASC` |
149149
| Direction || `ASC` (default) or `DESC` |
150-
| Multi-column || `ORDER BY name, age`only first column used |
150+
| Multi-column || `ORDER BY name, age`not supported |
151151
| Expressions || `ORDER BY UPPER(name)` — not supported |
152152

153-
- Column must exist in the worksheet headers.
153+
- Non-aggregate queries: ORDER BY column must exist in worksheet headers.
154+
- Aggregate queries (`GROUP BY` and/or aggregate SELECT): ORDER BY may reference:
155+
- projected output columns,
156+
- GROUP BY columns (even if not projected),
157+
- aggregate expressions (e.g., `COUNT(*)`, `SUM(score)`).
154158
- NULL values sort last (after all non-NULL values).
155159
- Numeric strings are compared numerically when both operands parse as numbers.
156160

src/excel_dbapi/executor.py

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -326,14 +326,17 @@ def _execute_aggregate_select(
326326
)
327327

328328
output_columns: list[str] = []
329+
required_aggregates: dict[str, tuple[str, str]] = {}
329330
for column in columns:
330331
if self._is_aggregate_column(column):
331332
arg = str(column.get("arg"))
332333
if arg != "*" and arg not in headers:
333334
raise ValueError(
334335
f"Unknown column: {arg}. Available columns: {headers}"
335336
)
336-
output_columns.append(self._aggregate_label(column))
337+
label = self._aggregate_label(column)
338+
required_aggregates[label] = (str(column.get("func")), arg)
339+
output_columns.append(label)
337340
continue
338341

339342
column_name = str(column)
@@ -351,6 +354,24 @@ def _execute_aggregate_select(
351354
)
352355
output_columns.append(column_name)
353356

357+
for clause in (having, parsed.get("order_by")):
358+
if clause is None:
359+
continue
360+
if "conditions" in clause:
361+
refs = [str(condition["column"]) for condition in clause["conditions"]]
362+
else:
363+
refs = [str(clause["column"])]
364+
for ref in refs:
365+
aggregate_spec = self._aggregate_spec_from_label(ref)
366+
if aggregate_spec is None:
367+
continue
368+
func, arg = aggregate_spec
369+
if arg != "*" and arg not in headers:
370+
raise ValueError(
371+
f"Unknown column: {arg}. Available columns: {headers}"
372+
)
373+
required_aggregates[ref] = (func, arg)
374+
354375
grouped_rows: list[dict[str, Any]] = []
355376
if group_by:
356377
groups: dict[tuple[Any, ...], list[dict[str, Any]]] = {}
@@ -360,33 +381,18 @@ def _execute_aggregate_select(
360381

361382
for group_key, group_values in groups.items():
362383
group_map = dict(zip(group_by, group_key))
363-
output_row: dict[str, Any] = {}
364-
for column in columns:
365-
if self._is_aggregate_column(column):
366-
label = self._aggregate_label(column)
367-
output_row[label] = self._compute_aggregate(
368-
str(column.get("func")),
369-
str(column.get("arg")),
370-
group_values,
371-
)
372-
else:
373-
col_name = str(column)
374-
output_row[col_name] = group_map.get(col_name)
375-
if having and not self._matches_where(output_row, having):
384+
context_row: dict[str, Any] = dict(group_map)
385+
for label, (func, arg) in required_aggregates.items():
386+
context_row[label] = self._compute_aggregate(func, arg, group_values)
387+
if having and not self._matches_where(context_row, having):
376388
continue
377-
grouped_rows.append(output_row)
389+
grouped_rows.append(context_row)
378390
else:
379-
output_row_single: dict[str, Any] = {}
380-
for column in columns:
381-
if self._is_aggregate_column(column):
382-
label = self._aggregate_label(column)
383-
output_row_single[label] = self._compute_aggregate(
384-
str(column.get("func")),
385-
str(column.get("arg")),
386-
rows,
387-
)
388-
if not having or self._matches_where(output_row_single, having):
389-
grouped_rows.append(output_row_single)
391+
context_row_single: dict[str, Any] = {}
392+
for label, (func, arg) in required_aggregates.items():
393+
context_row_single[label] = self._compute_aggregate(func, arg, rows)
394+
if not having or self._matches_where(context_row_single, having):
395+
grouped_rows.append(context_row_single)
390396

391397
distinct = parsed.get("distinct", False)
392398
if distinct:
@@ -402,9 +408,13 @@ def _execute_aggregate_select(
402408
order_by = parsed.get("order_by")
403409
if order_by:
404410
order_column = str(order_by["column"])
405-
if order_column not in output_columns:
411+
available_order_columns = set(output_columns)
412+
if group_by:
413+
available_order_columns.update(group_by)
414+
available_order_columns.update(required_aggregates.keys())
415+
if order_column not in available_order_columns:
406416
raise ValueError(
407-
f"Unknown column: {order_column}. Available columns: {output_columns}"
417+
f"Unknown column: {order_column}. Available columns: {sorted(available_order_columns)}"
408418
)
409419
reverse = order_by["direction"] == "DESC"
410420
grouped_rows = sorted(
@@ -445,6 +455,16 @@ def _is_aggregate_column(self, column: Any) -> bool:
445455
def _aggregate_label(self, aggregate: dict[str, Any]) -> str:
446456
return f"{str(aggregate['func']).upper()}({aggregate['arg']})"
447457

458+
def _aggregate_spec_from_label(self, expression: str) -> tuple[str, str] | None:
459+
match = re.fullmatch(r"(?i)(COUNT|SUM|AVG|MIN|MAX)\(([^\)]+)\)", expression.strip())
460+
if not match:
461+
return None
462+
func = match.group(1).upper()
463+
arg = match.group(2).strip()
464+
if not arg:
465+
return None
466+
return func, arg
467+
448468
def _compute_aggregate(
449469
self, func: str, arg: str, rows: list[dict[str, Any]]
450470
) -> Any:

src/excel_dbapi/parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ def _parse_select(query: str, params: Optional[tuple[Any, ...]]) -> Dict[str, An
469469
]
470470
order_end = min(order_end_candidates) if order_end_candidates else len(remainder)
471471
order_part = remainder[order_start:order_end].strip()
472+
order_part = _normalize_aggregate_expressions(order_part)
472473
order_tokens = order_part.split()
473474
if not order_tokens:
474475
raise ValueError("Invalid ORDER BY clause format")

tests/test_executor.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ def _create_empty_select_workbook(path: Path) -> None:
4848
workbook.save(path)
4949

5050

51+
def _create_users_workbook(path: Path) -> None:
52+
workbook = Workbook()
53+
sheet = workbook.active
54+
assert sheet is not None
55+
sheet.title = "users"
56+
sheet.append(["id", "name", "age"])
57+
sheet.append([1, "Alice", 30])
58+
sheet.append([2, "Bob", 25])
59+
sheet.append([3, "Alice", 35])
60+
sheet.append([4, "Charlie", 40])
61+
workbook.save(path)
62+
63+
5164
def test_executor_distinct_removes_duplicates_and_preserves_order(tmp_path: Path):
5265
file_path = tmp_path / "distinct_order.xlsx"
5366
_create_select_workbook(file_path)
@@ -222,3 +235,36 @@ def test_executor_distinct_with_group_by(tmp_path: Path):
222235
results = SharedExecutor(engine).execute(parsed)
223236

224237
assert results.rows == [("A",), ("B",), ("C",)]
238+
239+
240+
def test_executor_group_by_having_count_not_in_select(tmp_path: Path):
241+
file_path = tmp_path / "users_having_count_not_selected.xlsx"
242+
_create_users_workbook(file_path)
243+
244+
engine = OpenpyxlBackend(str(file_path))
245+
parsed = parse_sql("SELECT name FROM users GROUP BY name HAVING COUNT(*) > 1")
246+
results = SharedExecutor(engine).execute(parsed)
247+
248+
assert results.rows == [("Alice",)]
249+
250+
251+
def test_executor_group_by_having_group_column_not_in_select(tmp_path: Path):
252+
file_path = tmp_path / "users_having_group_column_not_selected.xlsx"
253+
_create_users_workbook(file_path)
254+
255+
engine = OpenpyxlBackend(str(file_path))
256+
parsed = parse_sql("SELECT COUNT(*) FROM users GROUP BY name HAVING name = 'Alice'")
257+
results = SharedExecutor(engine).execute(parsed)
258+
259+
assert results.rows == [(2,)]
260+
261+
262+
def test_executor_group_by_order_by_group_key_not_in_select(tmp_path: Path):
263+
file_path = tmp_path / "users_order_by_group_key_not_selected.xlsx"
264+
_create_users_workbook(file_path)
265+
266+
engine = OpenpyxlBackend(str(file_path))
267+
parsed = parse_sql("SELECT COUNT(*) FROM users GROUP BY name ORDER BY name")
268+
results = SharedExecutor(engine).execute(parsed)
269+
270+
assert results.rows == [(2,), (1,), (1,)]

tests/test_parser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ def test_parse_select_with_having_aggregate_expression():
139139
}
140140

141141

142+
def test_parse_select_order_by_aggregate_expression():
143+
parsed = parse_sql(
144+
"SELECT name, COUNT(*) FROM Sheet1 GROUP BY name ORDER BY COUNT(*) DESC"
145+
)
146+
assert parsed["order_by"] == {"column": "COUNT(*)", "direction": "DESC"}
147+
148+
142149
def test_parse_select_all_aggregate_functions():
143150
parsed = parse_sql(
144151
"SELECT COUNT(*), COUNT(score), SUM(score), AVG(score), MIN(score), MAX(score) FROM Sheet1"

0 commit comments

Comments
 (0)