minidb-sql 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
minidb_sql/query.py ADDED
@@ -0,0 +1,1077 @@
1
+ """Query execution engine for MiniDB."""
2
+
3
+ import functools
4
+ import re
5
+ from collections import defaultdict
6
+ from typing import Any
7
+
8
+ from .errors import ColumnNotFoundError, InvalidQueryError, TableNotFoundError, TypeMismatchError
9
+ from .parser import (
10
+ CaseExpr,
11
+ CaseResult,
12
+ Condition,
13
+ CreateTableQuery,
14
+ DeleteQuery,
15
+ DropTableQuery,
16
+ InsertQuery,
17
+ JoinClause,
18
+ OrderByItem,
19
+ SelectColumn,
20
+ SelectQuery,
21
+ UpdateQuery,
22
+ WhereClause,
23
+ )
24
+ from .planner import QueryPlanner, ScanType
25
+ from .table import Table
26
+ from .types import QueryResult, Row
27
+
28
+
29
+ def _assign_output(
30
+ result: dict[str, Any],
31
+ written: dict[str, str | None],
32
+ out_name: str,
33
+ value: Any,
34
+ qualifier: str | None,
35
+ ) -> None:
36
+ """Store a projected value. A second column with the same bare name gets a qualifier."""
37
+ if out_name not in result:
38
+ result[out_name] = value
39
+ written[out_name] = qualifier
40
+ return
41
+ previous = written.get(out_name)
42
+ if previous == qualifier:
43
+ result[out_name] = value
44
+ return
45
+ if previous:
46
+ result[f'{previous}.{out_name}'] = result.pop(out_name)
47
+ written.pop(out_name, None)
48
+ if qualifier:
49
+ result[f'{qualifier}.{out_name}'] = value
50
+ else:
51
+ result[out_name] = value
52
+ written[out_name] = None
53
+
54
+
55
+ class QueryExecutor:
56
+ """Executes parsed SQL queries against the database."""
57
+
58
+ def __init__(self, tables: dict[str, Table]):
59
+ self.tables = tables
60
+
61
+ def execute(self, query) -> QueryResult:
62
+ """Execute a parsed query."""
63
+ if isinstance(query, SelectQuery):
64
+ return self.execute_select(query)
65
+ elif isinstance(query, InsertQuery):
66
+ return self.execute_insert(query)
67
+ elif isinstance(query, UpdateQuery):
68
+ return self.execute_update(query)
69
+ elif isinstance(query, DeleteQuery):
70
+ return self.execute_delete(query)
71
+ elif isinstance(query, CreateTableQuery):
72
+ self.execute_create_table(query)
73
+ return None
74
+ elif isinstance(query, DropTableQuery):
75
+ self.execute_drop_table(query)
76
+ return None
77
+ else:
78
+ raise InvalidQueryError(f'Unknown query type: {type(query)}')
79
+
80
+ def execute_select(self, query: SelectQuery) -> list[Row]:
81
+ """Execute a SELECT query."""
82
+ # Get the main table
83
+ if query.table not in self.tables:
84
+ raise TableNotFoundError(query.table)
85
+
86
+ main_table = self.tables[query.table]
87
+ known_tables = self._known_tables(query)
88
+ for join in query.joins:
89
+ if join.table not in self.tables:
90
+ raise TableNotFoundError(join.table)
91
+ self._reject_unknown_query_columns(query, known_tables)
92
+
93
+ # Get rows based on query plan (indexed rows with row IDs)
94
+ if query.joins:
95
+ indexed_rows = self._execute_join(main_table, query)
96
+ else:
97
+ indexed_rows = self._execute_table_scan(main_table, query)
98
+
99
+ # Apply GROUP BY
100
+ ordered_source = False
101
+ if query.group_by:
102
+ result_rows = self._execute_group_by(indexed_rows, query)
103
+ elif self._has_aggregates(query.columns):
104
+ # Aggregate without GROUP BY - aggregate all rows
105
+ result_rows = self._execute_aggregation(indexed_rows, query)
106
+ else:
107
+ # Sort source rows first so ORDER BY can name a column that
108
+ # the SELECT list does not return.
109
+ if query.order_by:
110
+ if query.distinct:
111
+ self._reject_distinct_order_by(query)
112
+ indexed_rows = self._sort_indexed_rows(indexed_rows, query, known_tables)
113
+ ordered_source = True
114
+ result_rows = self._project_columns(indexed_rows, query.columns, query.table, known_tables)
115
+
116
+ if query.having:
117
+ if not query.group_by and not self._has_aggregates(query.columns):
118
+ raise InvalidQueryError('HAVING requires GROUP BY or aggregate functions')
119
+ result_rows = [row for row in result_rows if self._evaluate_where(row, query.having, known_tables)]
120
+
121
+ if query.distinct:
122
+ result_rows = self._deduplicate_rows(result_rows)
123
+
124
+ # Grouped rows still carry the group key and the alias. Plain
125
+ # selects were already ordered from the source rows.
126
+ if query.order_by and not ordered_source:
127
+ result_rows = self._execute_order_by(result_rows, query.order_by, known_tables)
128
+
129
+ if query.group_by or query.having or self._has_aggregates(query.columns):
130
+ keep = self._selected_output_keys(query)
131
+ result_rows = [{key: value for key, value in row.items() if key in keep} for row in result_rows]
132
+
133
+ # Apply LIMIT. A negative slice would drop rows from the end.
134
+ if query.limit is not None:
135
+ if query.limit < 0:
136
+ raise InvalidQueryError('LIMIT must be zero or positive')
137
+ result_rows = result_rows[: query.limit]
138
+
139
+ return result_rows
140
+
141
+ def _execute_table_scan(self, table: Table, query: SelectQuery) -> list[tuple[int, Row]]:
142
+ """Execute a table scan with optional WHERE filtering."""
143
+ planner = QueryPlanner(table)
144
+ plan = planner.plan_select(query)
145
+ known_tables = self._known_tables(query)
146
+ rows: list[tuple[int, Row]] = []
147
+ used_index = False
148
+
149
+ # Only an operator this scan can apply may probe the index.
150
+ # A leading LIKE, IN, or != must not hide a later comparison.
151
+ if plan.scan_type == ScanType.INDEX_SCAN and plan.index_column is not None and query.where is not None:
152
+ index = table.get_index(plan.index_column)
153
+ condition = self._find_index_condition(query.where, plan.index_column, ('=',))
154
+ if index is not None and condition is not None:
155
+ used_index = True
156
+ rows.extend(table.rows_for_ids(index.lookup(condition.value)))
157
+ elif plan.scan_type == ScanType.INDEX_RANGE_SCAN and plan.index_column is not None and query.where is not None:
158
+ index = table.get_index(plan.index_column)
159
+ condition = self._find_index_condition(query.where, plan.index_column, ('<', '<=', '>', '>='))
160
+ if index is not None and condition is not None:
161
+ used_index = True
162
+ rows.extend(table.rows_for_ids(index.range_lookup(condition.operator, condition.value)))
163
+
164
+ if not used_index:
165
+ for row_id, row in table.scan():
166
+ if query.where and not self._evaluate_where(row, query.where, known_tables):
167
+ continue
168
+ rows.append((row_id, row))
169
+ elif query.where:
170
+ rows = [(rid, row) for rid, row in rows if self._evaluate_where(row, query.where, known_tables)]
171
+
172
+ return rows
173
+
174
+ def _execute_join(self, left_table: Table, query: SelectQuery) -> list[tuple[int, Row]]:
175
+ """Execute a JOIN query.
176
+
177
+ Each new table joins to every table already in the FROM list.
178
+ ON b.id still names b after a later table is joined.
179
+ """
180
+ left_rows = []
181
+ for row_id, row in left_table.scan():
182
+ merged = dict(row)
183
+ if query.table:
184
+ for col, val in row.items():
185
+ merged[f'{query.table}.{col}'] = val
186
+ left_rows.append((row_id, merged))
187
+
188
+ seen = [query.table]
189
+ for join in query.joins:
190
+ if join.join_type == 'RIGHT':
191
+ raise InvalidQueryError('RIGHT JOIN is not supported; swap the tables and use LEFT JOIN')
192
+ if join.table not in self.tables:
193
+ raise TableNotFoundError(join.table)
194
+
195
+ right_table = self.tables[join.table]
196
+ left_spec, right_column = self._resolve_join_pair(seen, join, right_table)
197
+ right_index: dict[Any, list[tuple[int, Row]]] = defaultdict(list)
198
+ for rid, row in right_table.scan():
199
+ key = row.get(right_column)
200
+ if key is not None:
201
+ right_index[key].append((rid, row))
202
+
203
+ new_rows = []
204
+ for left_id, left_row in left_rows:
205
+ left_key = self._join_left_value(left_row, left_spec)
206
+ matches = right_index.get(left_key, []) if left_key is not None else []
207
+ if matches:
208
+ for _right_id, right_row in matches:
209
+ merged = dict(left_row)
210
+ for col, val in right_row.items():
211
+ if col not in merged:
212
+ merged[col] = val
213
+ merged[f'{join.table}.{col}'] = val
214
+ new_rows.append((left_id, merged))
215
+ elif join.join_type == 'LEFT':
216
+ merged = dict(left_row)
217
+ for col in right_table.columns:
218
+ if col not in merged:
219
+ merged[col] = None
220
+ merged[f'{join.table}.{col}'] = None
221
+ new_rows.append((left_id, merged))
222
+ seen.append(join.table)
223
+ left_rows = new_rows
224
+
225
+ if query.where:
226
+ known_tables = self._known_tables(query)
227
+ left_rows = [(rid, row) for rid, row in left_rows if self._evaluate_where(row, query.where, known_tables)]
228
+ return left_rows
229
+
230
+ def _resolve_join_pair(
231
+ self,
232
+ seen: list[str],
233
+ join: JoinClause,
234
+ right_table: Table,
235
+ ) -> tuple[tuple[str, str], str]:
236
+ """Return ((previous table, column), right-table column) for ON."""
237
+
238
+ def classify(qualifier: str | None, column: str) -> tuple[str, str, str]:
239
+ if qualifier is None:
240
+ return ('bare', '', column)
241
+ if qualifier == join.table:
242
+ if not right_table.schema.has_column(column):
243
+ raise ColumnNotFoundError(column, join.table)
244
+ return ('right', qualifier, column)
245
+ if qualifier in seen:
246
+ table = self.tables.get(qualifier)
247
+ if table is None or not table.schema.has_column(column):
248
+ raise ColumnNotFoundError(column, qualifier)
249
+ return ('left', qualifier, column)
250
+ raise InvalidQueryError(f"unknown table qualifier '{qualifier}'")
251
+
252
+ def resolve_bare(column: str) -> tuple[str, str, str]:
253
+ on_right = right_table.schema.has_column(column)
254
+ owners = [name for name in seen if name in self.tables and self.tables[name].schema.has_column(column)]
255
+ if on_right and owners:
256
+ raise InvalidQueryError(f"Column '{column}' is ambiguous")
257
+ if len(owners) > 1:
258
+ raise InvalidQueryError(f"Column '{column}' is ambiguous")
259
+ if on_right:
260
+ return ('right', join.table, column)
261
+ if len(owners) == 1:
262
+ return ('left', owners[0], column)
263
+ raise ColumnNotFoundError(column, join.table)
264
+
265
+ # Unqualified ON keeps operand order: first column is already joined,
266
+ # second column is the new table. `ON id = user_id` is users.id even
267
+ # when the new table also has id.
268
+ if join.left_table is None and join.right_table is None:
269
+ owners = [
270
+ name for name in seen if name in self.tables and self.tables[name].schema.has_column(join.left_column)
271
+ ]
272
+ if len(owners) != 1:
273
+ raise InvalidQueryError(f"Column '{join.left_column}' is ambiguous")
274
+ if not right_table.schema.has_column(join.right_column):
275
+ raise ColumnNotFoundError(join.right_column, join.table)
276
+ return (owners[0], join.left_column), join.right_column
277
+
278
+ sides = []
279
+ for qualifier, column in (
280
+ (join.left_table, join.left_column),
281
+ (join.right_table, join.right_column),
282
+ ):
283
+ kind, table, name = classify(qualifier, column)
284
+ if kind == 'bare':
285
+ kind, table, name = resolve_bare(name)
286
+ sides.append((kind, table, name))
287
+
288
+ lefts = [side for side in sides if side[0] == 'left']
289
+ rights = [side for side in sides if side[0] == 'right']
290
+ if len(lefts) != 1 or len(rights) != 1:
291
+ raise InvalidQueryError(f'JOIN {join.table} must compare that table to a table already in the FROM clause')
292
+ return (lefts[0][1], lefts[0][2]), rights[0][2]
293
+
294
+ def _join_left_value(self, row: Row, spec: tuple[str, str]) -> Any:
295
+ """Read the already-joined side of an ON predicate."""
296
+ table, column = spec
297
+ prefixed = f'{table}.{column}'
298
+ if prefixed in row:
299
+ return row[prefixed]
300
+ return row.get(column)
301
+
302
+ def _known_tables(self, query: SelectQuery) -> set[str]:
303
+ """Table names this SELECT can qualify."""
304
+ names = {query.table}
305
+ for join in query.joins:
306
+ names.add(join.table)
307
+ return names
308
+
309
+ def _find_index_condition(self, where: WhereClause, column: str, operators: tuple[str, ...]) -> Condition | None:
310
+ """Find a top-level predicate on column whose operator this scan can apply.
311
+
312
+ Nested OR arms are not probes. Parentheses wrap an AND conjunct in
313
+ its own WhereClause, and that comparison is still this scan's probe.
314
+ """
315
+ if not where:
316
+ return None
317
+
318
+ for cond in where.and_conjuncts():
319
+ if cond.column == column and not cond.negated and cond.value is not None and cond.operator in operators:
320
+ return cond
321
+
322
+ return None
323
+
324
+ def _require_column(
325
+ self,
326
+ column: str,
327
+ table_alias: str | None,
328
+ known_tables: set[str],
329
+ fallback_table: str,
330
+ ) -> None:
331
+ """Raise when a qualifier or bare name is not a column of this query."""
332
+ if table_alias:
333
+ if table_alias not in known_tables:
334
+ raise ColumnNotFoundError(column, table_alias)
335
+ table = self.tables.get(table_alias)
336
+ if table is None or not table.schema.has_column(column):
337
+ raise ColumnNotFoundError(column, table_alias)
338
+ return
339
+ if not any(self.tables[name].schema.has_column(column) for name in known_tables if name in self.tables):
340
+ raise ColumnNotFoundError(column, fallback_table)
341
+
342
+ def _reject_unknown_where(self, where: WhereClause | None, known_tables: set[str], fallback_table: str) -> None:
343
+ """Name check for WHERE. An empty index hit must not hide a bad column."""
344
+ if where is None:
345
+ return
346
+ for cond in where.conditions:
347
+ if isinstance(cond, WhereClause):
348
+ self._reject_unknown_where(cond, known_tables, fallback_table)
349
+ elif isinstance(cond, Condition):
350
+ self._require_column(cond.column, cond.table_alias, known_tables, fallback_table)
351
+
352
+ def _reject_unknown_query_columns(self, query: SelectQuery, known_tables: set[str]) -> None:
353
+ """Reject unknown SELECT, WHERE, and qualified ORDER BY names before scanning."""
354
+ for col in query.columns:
355
+ if col.case is not None:
356
+ for table_alias, column in self._case_columns(col.case):
357
+ self._require_column(column, table_alias, known_tables, query.table)
358
+ continue
359
+ if col.aggregate or col.name == '*':
360
+ continue
361
+ self._require_column(col.name, col.table_alias, known_tables, query.table)
362
+ self._reject_unknown_where(query.where, known_tables, query.table)
363
+ for item in query.order_by:
364
+ if item.table_alias:
365
+ self._require_column(item.column, item.table_alias, known_tables, query.table)
366
+
367
+ def _read_qualified_column(
368
+ self,
369
+ row: Row,
370
+ column: str,
371
+ table_alias: str | None,
372
+ known_tables: set[str] | None,
373
+ ) -> Any:
374
+ """Read a source-row column.
375
+
376
+ A qualifier that is not a table in this query, or a column that
377
+ table does not have, is missing even when the bare name exists.
378
+ A real qualifier on an unprefixed single-table row still reads
379
+ the bare value.
380
+ """
381
+ if table_alias:
382
+ if known_tables is not None and (
383
+ table_alias not in known_tables
384
+ or table_alias not in self.tables
385
+ or not self.tables[table_alias].schema.has_column(column)
386
+ ):
387
+ raise ColumnNotFoundError(column, table_alias)
388
+ prefixed = f'{table_alias}.{column}'
389
+ if prefixed in row:
390
+ return row[prefixed]
391
+ if any('.' in key for key in row):
392
+ raise ColumnNotFoundError(column, table_alias)
393
+ if column not in row:
394
+ raise ColumnNotFoundError(column, table_alias)
395
+ return row[column]
396
+
397
+ def _evaluate_where(self, row: Row, where: WhereClause, known_tables: set[str] | None = None) -> bool:
398
+ """Evaluate a WHERE clause against a row."""
399
+ results = []
400
+
401
+ for cond in where.conditions:
402
+ if isinstance(cond, Condition):
403
+ results.append(self._evaluate_condition(row, cond, known_tables))
404
+ elif isinstance(cond, WhereClause):
405
+ results.append(self._evaluate_where(row, cond, known_tables))
406
+
407
+ if where.operator == 'AND':
408
+ return all(results)
409
+ else: # OR
410
+ return any(results)
411
+
412
+ def _evaluate_condition(self, row: Row, condition: Condition, known_tables: set[str] | None = None) -> bool:
413
+ """Evaluate a single condition against a row."""
414
+ value = self._read_qualified_column(row, condition.column, condition.table_alias, known_tables)
415
+ cond_value = condition.value
416
+ op = condition.operator
417
+
418
+ if op == 'IS NULL':
419
+ result = value is None
420
+ elif op == 'IS NOT NULL':
421
+ result = value is not None
422
+ elif value is None or (cond_value is None and op in ('=', '!=', '>', '>=', '<', '<=', 'LIKE', 'IN')):
423
+ # NULL compared with =, !=, <, or LIKE is unknown. NOT unknown
424
+ # is still unknown, so the row is dropped either way.
425
+ return False
426
+ elif op in ('=', '!=', '>', '>=', '<', '<='):
427
+ self._reject_mismatched_compare(condition.column, value, cond_value)
428
+ if op == '=':
429
+ result = value == cond_value
430
+ elif op == '!=':
431
+ result = value != cond_value
432
+ else:
433
+ try:
434
+ if op == '>':
435
+ result = value > cond_value
436
+ elif op == '>=':
437
+ result = value >= cond_value
438
+ elif op == '<':
439
+ result = value < cond_value
440
+ else:
441
+ result = value <= cond_value
442
+ except TypeError as exc:
443
+ raise TypeMismatchError(condition.column, type(value).__name__, type(cond_value).__name__) from exc
444
+ elif op == 'LIKE':
445
+ result = self._match_like(str(value), str(cond_value))
446
+ elif op == 'IN':
447
+ # NULL in the list makes the comparison unknown. WHERE drops unknown,
448
+ # including NOT IN, instead of using Python membership.
449
+ if isinstance(cond_value, (list, tuple)) and any(item is None for item in cond_value):
450
+ if any(item is not None and value == item for item in cond_value):
451
+ result = True
452
+ else:
453
+ return False
454
+ else:
455
+ for item in cond_value:
456
+ if item is not None:
457
+ self._reject_mismatched_compare(condition.column, value, item)
458
+ result = value in cond_value
459
+ else:
460
+ return False
461
+
462
+ if condition.negated:
463
+ return not result
464
+ return result
465
+
466
+ def _reject_mismatched_compare(self, column: str, left: Any, right: Any) -> None:
467
+ """Reject comparisons that Python would answer with a different type's rules.
468
+
469
+ True compares equal to 1. A boolean predicate must not match an integer
470
+ primary key, and an integer must not match a boolean column.
471
+ """
472
+ if isinstance(left, bool) or isinstance(right, bool):
473
+ if isinstance(left, bool) and isinstance(right, bool):
474
+ return
475
+ raise TypeMismatchError(column, type(left).__name__, type(right).__name__)
476
+
477
+ def _match_like(self, value: str, pattern: str) -> bool:
478
+ """Match a value against a LIKE pattern."""
479
+
480
+ # Convert SQL LIKE pattern to regex
481
+ # % matches any sequence, _ matches single character
482
+ regex_pattern = '^'
483
+ for char in pattern:
484
+ if char == '%':
485
+ regex_pattern += '.*'
486
+ elif char == '_':
487
+ regex_pattern += '.'
488
+ else:
489
+ regex_pattern += re.escape(char)
490
+ regex_pattern += '$'
491
+
492
+ # LIKE stays case-insensitive. Equality stays case-sensitive.
493
+ return bool(re.match(regex_pattern, value, re.IGNORECASE))
494
+
495
+ def _deduplicate_rows(self, rows: list[Row]) -> list[Row]:
496
+ """Drop duplicate projected rows, keeping first-seen order."""
497
+ seen: set[tuple] = set()
498
+ unique: list[Row] = []
499
+ for row in rows:
500
+ key = tuple(row.items())
501
+ if key not in seen:
502
+ seen.add(key)
503
+ unique.append(row)
504
+ return unique
505
+
506
+ def _case_columns(self, expr: CaseExpr) -> list[tuple[str | None, str]]:
507
+ """Column references inside WHEN, THEN, and ELSE."""
508
+ refs: list[tuple[str | None, str]] = []
509
+
510
+ def add_where(where: WhereClause) -> None:
511
+ for cond in where.conditions:
512
+ if isinstance(cond, Condition):
513
+ refs.append((cond.table_alias, cond.column))
514
+ elif isinstance(cond, WhereClause):
515
+ add_where(cond)
516
+
517
+ for when in expr.whens:
518
+ add_where(when.condition)
519
+ if when.result.column is not None:
520
+ refs.append((when.result.table_alias, when.result.column))
521
+ if expr.else_result is not None and expr.else_result.column is not None:
522
+ refs.append((expr.else_result.table_alias, expr.else_result.column))
523
+ return refs
524
+
525
+ def _reject_case_grouping(self, expr: CaseExpr, group_by: list[str]) -> None:
526
+ """A CASE column must be grouped, same as a bare select column."""
527
+ for table_alias, column in self._case_columns(expr):
528
+ grouped = self._select_is_grouped(SelectColumn(name=column, table_alias=table_alias), group_by)
529
+ if not grouped:
530
+ raise InvalidQueryError(f"Column '{column}' must appear in GROUP BY or be aggregated")
531
+
532
+ def _evaluate_case(self, row: Row, expr: CaseExpr, known: set[str] | None) -> Any:
533
+ """First true WHEN wins. Unknown is not true. No ELSE is NULL."""
534
+ for when in expr.whens:
535
+ if self._evaluate_where(row, when.condition, known):
536
+ return self._case_result_value(row, when.result, known)
537
+ if expr.else_result is None:
538
+ return None
539
+ return self._case_result_value(row, expr.else_result, known)
540
+
541
+ def _case_result_value(self, row: Row, result: CaseResult, known: set[str] | None) -> Any:
542
+ if result.column is None:
543
+ return result.literal
544
+ return self._read_qualified_column(row, result.column, result.table_alias, known)
545
+
546
+ def _has_aggregates(self, columns: list[SelectColumn]) -> bool:
547
+ """Check if any column has an aggregate function."""
548
+ return any(col.aggregate for col in columns)
549
+
550
+ def _column_in_query_schema(self, column: str, query: SelectQuery) -> bool:
551
+ """Return True if column exists on the FROM table or any JOIN table."""
552
+ main = self.tables.get(query.table)
553
+ if main is not None and main.schema.has_column(column):
554
+ return True
555
+ for join in query.joins:
556
+ joined = self.tables.get(join.table)
557
+ if joined is not None and joined.schema.has_column(column):
558
+ return True
559
+ return False
560
+
561
+ def _column_owner_count(self, column: str, known: set[str]) -> int:
562
+ return sum(1 for name in known if name in self.tables and self.tables[name].schema.has_column(column))
563
+
564
+ def _require_group_column(self, spec: str, known: set[str], query: SelectQuery) -> None:
565
+ if '.' in spec:
566
+ table, column = spec.split('.', 1)
567
+ self._require_column(column, table, known, query.table)
568
+ return
569
+ if self._column_owner_count(spec, known) > 1:
570
+ raise InvalidQueryError(f"Column '{spec}' is ambiguous")
571
+ if not self._column_in_query_schema(spec, query):
572
+ raise ColumnNotFoundError(spec, query.table)
573
+
574
+ def _group_value(self, row: Row, spec: str, known: set[str]) -> Any:
575
+ if '.' in spec:
576
+ table, column = spec.split('.', 1)
577
+ return self._read_qualified_column(row, column, table, known)
578
+ owners = [name for name in known if name in self.tables and self.tables[name].schema.has_column(spec)]
579
+ if len(owners) == 1:
580
+ prefixed = f'{owners[0]}.{spec}'
581
+ if prefixed in row:
582
+ return row[prefixed]
583
+ return row.get(spec)
584
+
585
+ def _aggregate_label(self, col: SelectColumn) -> str:
586
+ if col.alias:
587
+ return col.alias
588
+ assert col.aggregate is not None
589
+ argument = col.aggregate.column
590
+ if col.table_alias:
591
+ argument = f'{col.table_alias}.{argument}'
592
+ return f'{col.aggregate.function}({argument})'
593
+
594
+ def _aggregate_cell(self, row: Row, column: str, table_alias: str | None, known: set[str]) -> Any:
595
+ if table_alias:
596
+ return self._read_qualified_column(row, column, table_alias, known)
597
+ owners = [name for name in known if name in self.tables and self.tables[name].schema.has_column(column)]
598
+ if len(owners) == 1:
599
+ prefixed = f'{owners[0]}.{column}'
600
+ if prefixed in row:
601
+ return row[prefixed]
602
+ return row.get(column)
603
+
604
+ def _bare_name_is_grouped(self, name: str, group_by: list[str]) -> bool:
605
+ for spec in group_by:
606
+ column = spec.split('.', 1)[1] if '.' in spec else spec
607
+ if column == name:
608
+ return True
609
+ return False
610
+
611
+ def _select_is_grouped(self, sel: SelectColumn, group_by: list[str]) -> bool:
612
+ for spec in group_by:
613
+ if '.' in spec:
614
+ table, column = spec.split('.', 1)
615
+ if sel.name == column and (sel.table_alias is None or sel.table_alias == table):
616
+ return True
617
+ elif sel.name == spec:
618
+ return True
619
+ return False
620
+
621
+ def _grouped_select_value(self, row: Row, sel: SelectColumn, group_by: list[str], known: set[str]) -> Any:
622
+ for spec in group_by:
623
+ if '.' in spec:
624
+ table, column = spec.split('.', 1)
625
+ if sel.name == column and (sel.table_alias is None or sel.table_alias == table):
626
+ return self._read_qualified_column(row, column, table, known)
627
+ elif sel.name == spec:
628
+ return self._group_value(row, spec, known)
629
+ return row.get(sel.name)
630
+
631
+ def _execute_group_by(self, rows: list[tuple[int, Row]], query: SelectQuery) -> list[Row]:
632
+ """Execute GROUP BY aggregation."""
633
+ known = self._known_tables(query)
634
+ for spec in query.group_by:
635
+ self._require_group_column(spec, known, query)
636
+
637
+ groups = defaultdict(list)
638
+
639
+ for _row_id, row in rows:
640
+ key = tuple(self._group_value(row, spec, known) for spec in query.group_by)
641
+ groups[key].append(row)
642
+
643
+ results = []
644
+ for key, group_rows in groups.items():
645
+ result_row = {}
646
+
647
+ # Add group by columns
648
+ for i, group_col in enumerate(query.group_by):
649
+ result_row[group_col] = key[i]
650
+
651
+ # Add selected columns. A bare column or * that is not grouped
652
+ # would otherwise keep one arbitrary value and hide the rest.
653
+ sample = group_rows[0]
654
+ for sel_col in query.columns:
655
+ if sel_col.aggregate:
656
+ agg_value = self._compute_aggregate(
657
+ sel_col.aggregate.function,
658
+ sel_col.aggregate.column,
659
+ group_rows,
660
+ query,
661
+ sel_col.table_alias,
662
+ )
663
+ agg_name = self._aggregate_label(sel_col)
664
+ if sel_col.alias and agg_name in result_row:
665
+ raise InvalidQueryError(f"Duplicate column name '{sel_col.alias}'")
666
+ result_row[agg_name] = agg_value
667
+ elif sel_col.case is not None:
668
+ self._reject_case_grouping(sel_col.case, query.group_by)
669
+ value = self._evaluate_case(sample, sel_col.case, known)
670
+ out_name = sel_col.alias or 'CASE'
671
+ if out_name in result_row:
672
+ raise InvalidQueryError(f"Duplicate column name '{out_name}'")
673
+ result_row[out_name] = value
674
+ elif sel_col.name == '*':
675
+ hidden = [
676
+ key for key in sample if '.' not in key and not self._bare_name_is_grouped(key, query.group_by)
677
+ ]
678
+ if hidden:
679
+ raise InvalidQueryError(f'SELECT * with GROUP BY leaves {hidden[0]!r} ungrouped')
680
+ continue
681
+ elif not self._select_is_grouped(sel_col, query.group_by):
682
+ if sel_col.table_alias is None and self._column_owner_count(sel_col.name, known) > 1:
683
+ raise InvalidQueryError(f"Column '{sel_col.name}' is ambiguous")
684
+ raise InvalidQueryError(f"Column '{sel_col.name}' must appear in GROUP BY or be aggregated")
685
+ else:
686
+ value = self._grouped_select_value(sample, sel_col, query.group_by, known)
687
+ out_name = sel_col.alias or sel_col.name
688
+ if sel_col.alias and out_name in result_row:
689
+ raise InvalidQueryError(f"Duplicate column name '{sel_col.alias}'")
690
+ result_row[out_name] = value
691
+
692
+ for spec in self._having_aggregate_names(query.having):
693
+ if spec not in result_row:
694
+ func, column = self._split_aggregate_spec(spec)
695
+ result_row[spec] = self._compute_aggregate(func, column, group_rows, query)
696
+
697
+ results.append(result_row)
698
+
699
+ return results
700
+
701
+ def _having_aggregate_names(self, having: WhereClause | None) -> list[str]:
702
+ """Collect FUNC(col) names referenced by HAVING."""
703
+ if having is None:
704
+ return []
705
+ names: list[str] = []
706
+ for cond in having.conditions:
707
+ if isinstance(cond, Condition):
708
+ if '(' in cond.column and cond.column.endswith(')'):
709
+ names.append(cond.column)
710
+ elif isinstance(cond, WhereClause):
711
+ names.extend(self._having_aggregate_names(cond))
712
+ return names
713
+
714
+ def _split_aggregate_spec(self, spec: str) -> tuple[str, str]:
715
+ """Split 'COUNT(*)' or 'SUM(quantity)' into (function, column)."""
716
+ func, rest = spec.split('(', 1)
717
+ return func, rest[:-1]
718
+
719
+ def _selected_output_keys(self, query: SelectQuery) -> set[str]:
720
+ """Keys that belong in the user-visible SELECT output."""
721
+ alias_for: dict[str, str] = {}
722
+ for col in query.columns:
723
+ if col.alias and not col.aggregate and col.name != '*':
724
+ alias_for[col.name] = col.alias
725
+ keys: set[str] = set()
726
+ for name in query.group_by:
727
+ if '.' not in name:
728
+ keys.add(alias_for.get(name, name))
729
+ continue
730
+ table, column = name.split('.', 1)
731
+ shown = any(
732
+ col.name == column and (col.table_alias is None or col.table_alias == table)
733
+ for col in query.columns
734
+ if col.aggregate is None and col.name != '*'
735
+ )
736
+ if not shown:
737
+ keys.add(name)
738
+ for col in query.columns:
739
+ if col.case is not None:
740
+ keys.add(col.alias or 'CASE')
741
+ elif col.aggregate:
742
+ keys.add(self._aggregate_label(col))
743
+ elif col.name != '*':
744
+ keys.add(col.alias or col.name)
745
+ return keys
746
+
747
+ def _execute_aggregation(self, rows: list[tuple[int, Row]], query: SelectQuery) -> list[Row]:
748
+ """Execute aggregation without GROUP BY."""
749
+ for col in query.columns:
750
+ if col.case is not None:
751
+ self._reject_case_grouping(col.case, [])
752
+ bare = [col.name for col in query.columns if not col.aggregate and col.case is None and col.name != '*']
753
+ if bare:
754
+ raise InvalidQueryError(f"Column '{bare[0]}' must appear in GROUP BY or be aggregated")
755
+ if any(col.name == '*' and not col.aggregate for col in query.columns):
756
+ raise InvalidQueryError('SELECT * cannot be combined with aggregates without GROUP BY')
757
+
758
+ row_list = [row for _, row in rows]
759
+ result_row = {}
760
+
761
+ for col in query.columns:
762
+ if col.aggregate:
763
+ agg_value = self._compute_aggregate(
764
+ col.aggregate.function, col.aggregate.column, row_list, query, col.table_alias
765
+ )
766
+ agg_name = self._aggregate_label(col)
767
+ if col.alias and agg_name in result_row:
768
+ raise InvalidQueryError(f"Duplicate column name '{col.alias}'")
769
+ result_row[agg_name] = agg_value
770
+
771
+ for spec in self._having_aggregate_names(query.having):
772
+ if spec not in result_row:
773
+ func, column = self._split_aggregate_spec(spec)
774
+ result_row[spec] = self._compute_aggregate(func, column, row_list, query)
775
+
776
+ return [result_row] if result_row else []
777
+
778
+ def _compute_aggregate(
779
+ self,
780
+ func: str,
781
+ column: str,
782
+ rows: list[Row],
783
+ query: SelectQuery,
784
+ table_alias: str | None = None,
785
+ ) -> Any:
786
+ """Compute an aggregate function value."""
787
+ known = self._known_tables(query)
788
+ if table_alias is None and column not in ('*',) and '.' in column:
789
+ table_alias, column = column.split('.', 1)
790
+ if column != '*':
791
+ if table_alias:
792
+ self._require_column(column, table_alias, known, query.table)
793
+ elif not self._column_in_query_schema(column, query):
794
+ raise ColumnNotFoundError(column, query.table)
795
+ elif self._column_owner_count(column, known) > 1:
796
+ raise InvalidQueryError(f"Column '{column}' is ambiguous")
797
+
798
+ def cell(row: Row) -> Any:
799
+ return self._aggregate_cell(row, column, table_alias, known)
800
+
801
+ if func == 'COUNT':
802
+ if column == '*':
803
+ return len(rows)
804
+ return sum(1 for row in rows if cell(row) is not None)
805
+
806
+ values: list[Any] = [v for row in rows if (v := cell(row)) is not None]
807
+
808
+ if not values:
809
+ return None
810
+
811
+ try:
812
+ if func == 'SUM':
813
+ return sum(values)
814
+ elif func == 'AVG':
815
+ return sum(values) / len(values)
816
+ elif func == 'MIN':
817
+ return min(values)
818
+ elif func == 'MAX':
819
+ return max(values)
820
+ except TypeError as exc:
821
+ raise TypeMismatchError(column, 'number', type(values[0]).__name__) from exc
822
+
823
+ return None
824
+
825
+ def _project_columns(
826
+ self,
827
+ rows: list[tuple[int, Row]],
828
+ columns: list[SelectColumn],
829
+ table_name: str | None = None,
830
+ known_tables: set[str] | None = None,
831
+ ) -> list[Row]:
832
+ """Project selected columns from rows."""
833
+ results = []
834
+
835
+ for _row_id, row in rows:
836
+ result: dict[str, Any] = {}
837
+
838
+ # Bare output name -> qualifier that produced it, if any.
839
+ # Two tables both exposing `id` must not share one key.
840
+ written: dict[str, str | None] = {}
841
+
842
+ for col in columns:
843
+ if col.name == '*':
844
+ if col.table_alias:
845
+ source = self.tables.get(col.table_alias)
846
+ if source is None:
847
+ raise ColumnNotFoundError('*', col.table_alias)
848
+ for schema_col in source.columns:
849
+ value = self._read_qualified_column(row, schema_col, col.table_alias, known_tables)
850
+ _assign_output(result, written, schema_col, value, col.table_alias)
851
+ continue
852
+ # Select all columns. Skip prefixed copies unless the
853
+ # bare name already belongs to the other table.
854
+ for key, val in row.items():
855
+ if '.' not in key:
856
+ _assign_output(result, written, key, val, None)
857
+ for key, val in row.items():
858
+ if '.' not in key:
859
+ continue
860
+ qualifier, bare = key.split('.', 1)
861
+ same_source = written.get(bare) in (None, qualifier)
862
+ if bare in result and (not same_source or result[bare] != val):
863
+ _assign_output(result, written, bare, val, qualifier)
864
+ elif col.aggregate:
865
+ # Aggregates handled separately
866
+ pass
867
+ elif col.case is not None:
868
+ value = self._evaluate_case(row, col.case, known_tables)
869
+ out_name = col.alias or 'CASE'
870
+ if out_name in result:
871
+ raise InvalidQueryError(f"Duplicate column name '{out_name}'")
872
+ _assign_output(result, written, out_name, value, None)
873
+ else:
874
+ col_name = col.name
875
+ out_name = col.alias or col_name
876
+ if col.alias and out_name in result:
877
+ raise InvalidQueryError(f"Duplicate column name '{col.alias}'")
878
+ if col.table_alias:
879
+ value = self._read_qualified_column(row, col_name, col.table_alias, known_tables)
880
+ _assign_output(result, written, out_name, value, None if col.alias else col.table_alias)
881
+ continue
882
+
883
+ if col_name in row:
884
+ _assign_output(result, written, out_name, row[col_name], None)
885
+ else:
886
+ raise ColumnNotFoundError(col_name, table_name)
887
+
888
+ results.append(result)
889
+
890
+ return results
891
+
892
+ def _reject_distinct_order_by(self, query: SelectQuery) -> None:
893
+ """DISTINCT keeps one output row. A hidden sort key would pick which one."""
894
+ if any(col.name == '*' and col.aggregate is None for col in query.columns):
895
+ return
896
+ visible: set[str] = set()
897
+ for col in query.columns:
898
+ if col.case is not None:
899
+ visible.add(col.alias or 'CASE')
900
+ continue
901
+ if col.aggregate is not None:
902
+ function = col.aggregate.function
903
+ argument = col.aggregate.column
904
+ visible.add(col.alias or f'{function}({argument})')
905
+ continue
906
+ visible.add(col.alias or col.name)
907
+ for item in query.order_by:
908
+ if item.column not in visible:
909
+ raise InvalidQueryError(f'ORDER BY {item.column} must appear in the SELECT list when DISTINCT is used')
910
+
911
+ def _sort_indexed_rows(
912
+ self,
913
+ rows: list[tuple[int, Row]],
914
+ query: SelectQuery,
915
+ known_tables: set[str],
916
+ ) -> list[tuple[int, Row]]:
917
+ """Order source rows, including columns the SELECT list omits."""
918
+ views: list[Row] = []
919
+ paired: list[tuple[Row, tuple[int, Row]]] = []
920
+ for indexed in rows:
921
+ _row_id, row = indexed
922
+ view = dict(row)
923
+ for col in query.columns:
924
+ if col.case is not None:
925
+ view[col.alias or 'CASE'] = self._evaluate_case(row, col.case, known_tables)
926
+ continue
927
+ if col.alias is None or col.aggregate is not None or col.name == '*':
928
+ continue
929
+ if col.table_alias:
930
+ view[col.alias] = self._read_qualified_column(row, col.name, col.table_alias, known_tables)
931
+ elif col.name in row:
932
+ view[col.alias] = row[col.name]
933
+ views.append(view)
934
+ paired.append((view, indexed))
935
+ ordered = self._execute_order_by(views, query.order_by, known_tables)
936
+ by_identity = {id(view): indexed for view, indexed in paired}
937
+ return [by_identity[id(view)] for view in ordered]
938
+
939
+ def _execute_order_by(
940
+ self,
941
+ rows: list[Row],
942
+ order_by: list[OrderByItem],
943
+ known_tables: set[str] | None = None,
944
+ ) -> list[Row]:
945
+ """Sort rows by ORDER BY columns with per-column direction."""
946
+ for item in order_by:
947
+ if item.table_alias and known_tables is not None:
948
+ self._require_column(item.column, item.table_alias, known_tables, item.table_alias)
949
+ if rows:
950
+ sample = rows[0]
951
+ for item in order_by:
952
+ if item.table_alias:
953
+ prefixed = f'{item.table_alias}.{item.column}'
954
+ if prefixed not in sample and item.column not in sample:
955
+ raise ColumnNotFoundError(item.column, item.table_alias)
956
+ elif item.column not in sample:
957
+ raise ColumnNotFoundError(item.column)
958
+
959
+ def _order_value(row: Row, item: OrderByItem) -> Any:
960
+ if item.table_alias:
961
+ prefixed = f'{item.table_alias}.{item.column}'
962
+ if prefixed in row:
963
+ return row[prefixed]
964
+ return row.get(item.column)
965
+
966
+ def _compare_rows(a: Row, b: Row) -> int:
967
+ for item in order_by:
968
+ val_a = _order_value(a, item)
969
+ val_b = _order_value(b, item)
970
+
971
+ # NULLs sort last regardless of direction
972
+ if val_a is None and val_b is None:
973
+ continue
974
+ if val_a is None:
975
+ return 1
976
+ if val_b is None:
977
+ return -1
978
+
979
+ # Compare values
980
+ if val_a < val_b:
981
+ cmp = -1
982
+ elif val_a > val_b:
983
+ cmp = 1
984
+ else:
985
+ continue
986
+
987
+ # Reverse for DESC
988
+ if item.direction == 'DESC':
989
+ cmp = -cmp
990
+
991
+ return cmp
992
+
993
+ return 0
994
+
995
+ return sorted(rows, key=functools.cmp_to_key(_compare_rows))
996
+
997
+ def execute_insert(self, query: InsertQuery) -> int:
998
+ """Execute an INSERT query."""
999
+ if query.table not in self.tables:
1000
+ raise TableNotFoundError(query.table)
1001
+
1002
+ table = self.tables[query.table]
1003
+
1004
+ if len(query.columns) != len(query.values):
1005
+ raise InvalidQueryError(
1006
+ f'column count ({len(query.columns)}) does not match value count ({len(query.values)})'
1007
+ )
1008
+
1009
+ for col in query.columns:
1010
+ if not table.schema.has_column(col):
1011
+ raise ColumnNotFoundError(col, query.table)
1012
+
1013
+ seen_columns: set[str] = set()
1014
+ for col in query.columns:
1015
+ if col in seen_columns:
1016
+ raise InvalidQueryError(f"Duplicate column name '{col}'")
1017
+ seen_columns.add(col)
1018
+
1019
+ row = dict(zip(query.columns, query.values, strict=True))
1020
+
1021
+ row_id = table.insert(row)
1022
+ return row_id
1023
+
1024
+ def execute_update(self, query: UpdateQuery) -> int:
1025
+ """Execute an UPDATE query."""
1026
+ if query.table not in self.tables:
1027
+ raise TableNotFoundError(query.table)
1028
+
1029
+ table = self.tables[query.table]
1030
+
1031
+ validated_updates: dict[str, Any] = {}
1032
+ for col, value in query.set_clause.items():
1033
+ column = table.schema.get_column(col)
1034
+ if column is None:
1035
+ raise ColumnNotFoundError(col, query.table)
1036
+ validated_updates[col] = column.validate(value)
1037
+
1038
+ # Find matching rows
1039
+ known_tables = {query.table}
1040
+ self._reject_unknown_where(query.where, known_tables, query.table)
1041
+ row_ids = set()
1042
+ for row_id, row in table.scan():
1043
+ if query.where and not self._evaluate_where(row, query.where, known_tables):
1044
+ continue
1045
+ row_ids.add(row_id)
1046
+
1047
+ # Update rows
1048
+ return table.update_rows(row_ids, validated_updates)
1049
+
1050
+ def execute_delete(self, query: DeleteQuery) -> int:
1051
+ """Execute a DELETE query."""
1052
+ if query.table not in self.tables:
1053
+ raise TableNotFoundError(query.table)
1054
+
1055
+ table = self.tables[query.table]
1056
+
1057
+ # Find matching rows
1058
+ known_tables = {query.table}
1059
+ self._reject_unknown_where(query.where, known_tables, query.table)
1060
+ row_ids = set()
1061
+ for row_id, row in table.scan():
1062
+ if query.where and not self._evaluate_where(row, query.where, known_tables):
1063
+ continue
1064
+ row_ids.add(row_id)
1065
+
1066
+ # Delete rows
1067
+ return table.delete_rows(row_ids)
1068
+
1069
+ def execute_create_table(self, query: CreateTableQuery) -> None:
1070
+ """Execute a CREATE TABLE query."""
1071
+ # This is handled by the Database class
1072
+ raise InvalidQueryError('CREATE TABLE should be handled by MiniDB.execute()')
1073
+
1074
+ def execute_drop_table(self, query: DropTableQuery) -> None:
1075
+ """Execute a DROP TABLE query."""
1076
+ # This is handled by the Database class
1077
+ raise InvalidQueryError('DROP TABLE should be handled by MiniDB.execute()')