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/__init__.py +168 -0
- minidb_sql/column.py +170 -0
- minidb_sql/database.py +258 -0
- minidb_sql/errors.py +176 -0
- minidb_sql/index.py +222 -0
- minidb_sql/parser.py +1278 -0
- minidb_sql/persistence.py +72 -0
- minidb_sql/planner.py +167 -0
- minidb_sql/query.py +1077 -0
- minidb_sql/table.py +274 -0
- minidb_sql/types.py +189 -0
- minidb_sql-1.3.0.dist-info/METADATA +379 -0
- minidb_sql-1.3.0.dist-info/RECORD +16 -0
- minidb_sql-1.3.0.dist-info/WHEEL +5 -0
- minidb_sql-1.3.0.dist-info/licenses/LICENSE +21 -0
- minidb_sql-1.3.0.dist-info/top_level.txt +1 -0
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()')
|