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/parser.py ADDED
@@ -0,0 +1,1278 @@
1
+ """SQL parser for MiniDB."""
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from .errors import InvalidQueryError, SyntaxError_
8
+ from .types import ColumnType, TokenType
9
+
10
+
11
+ @dataclass
12
+ class Token:
13
+ """Represents a single token from the lexer."""
14
+
15
+ type: TokenType
16
+ value: Any
17
+ position: int
18
+
19
+
20
+ @dataclass
21
+ class ParamPlaceholder:
22
+ """Sentinel for a ? parameter that is bound after parse."""
23
+
24
+
25
+ @dataclass
26
+ class Condition:
27
+ """Represents a WHERE condition."""
28
+
29
+ column: str
30
+ operator: str # '=', '!=', '>', '>=', '<', '<=', 'LIKE', 'IN', 'IS NULL', 'IS NOT NULL'
31
+ value: Any
32
+ table_alias: str | None = None # For JOIN column references
33
+ negated: bool = False
34
+
35
+
36
+ @dataclass
37
+ class WhereClause:
38
+ """Represents a WHERE clause with AND/OR logic."""
39
+
40
+ conditions: list # List of Condition or WhereClause (nested)
41
+ operator: str = 'AND' # 'AND' or 'OR'
42
+
43
+ def and_conjuncts(self) -> list[Condition]:
44
+ """AND predicates, including a comparison wrapped in parentheses.
45
+
46
+ A parenthesized comparison is its own WhereClause. Nested OR is a
47
+ different predicate and must not become an index probe.
48
+ """
49
+ if self.operator != 'AND':
50
+ return []
51
+ found: list[Condition] = []
52
+ for cond in self.conditions:
53
+ if isinstance(cond, Condition):
54
+ found.append(cond)
55
+ elif isinstance(cond, WhereClause) and cond.operator == 'AND':
56
+ found.extend(cond.and_conjuncts())
57
+ return found
58
+
59
+
60
+ @dataclass
61
+ class JoinClause:
62
+ """Represents a JOIN clause."""
63
+
64
+ table: str
65
+ left_column: str
66
+ right_column: str
67
+ join_type: str = 'INNER' # 'INNER', 'LEFT', 'RIGHT'
68
+ left_table: str | None = None
69
+ right_table: str | None = None
70
+
71
+
72
+ @dataclass
73
+ class OrderByItem:
74
+ """Represents an ORDER BY item."""
75
+
76
+ column: str
77
+ direction: str = 'ASC'
78
+ table_alias: str | None = None
79
+
80
+
81
+ @dataclass
82
+ class CaseResult:
83
+ """A THEN or ELSE value. column is set for a column reference."""
84
+
85
+ literal: Any = None
86
+ column: str | None = None
87
+ table_alias: str | None = None
88
+
89
+
90
+ @dataclass
91
+ class CaseWhen:
92
+ """One searched WHEN arm. condition uses the WHERE grammar."""
93
+
94
+ condition: WhereClause
95
+ result: CaseResult
96
+
97
+
98
+ @dataclass
99
+ class CaseExpr:
100
+ """Searched CASE. else_result None means no ELSE, which is NULL."""
101
+
102
+ whens: list[CaseWhen]
103
+ else_result: CaseResult | None = None
104
+
105
+
106
+ @dataclass
107
+ class AggregateFunction:
108
+ """Represents an aggregate function call."""
109
+
110
+ function: str # 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX'
111
+ column: str # Column name or '*' for COUNT(*)
112
+ alias: str | None = None
113
+
114
+
115
+ @dataclass
116
+ class SelectColumn:
117
+ """Represents a column in a SELECT statement."""
118
+
119
+ name: str # Column name or '*'
120
+ table_alias: str | None = None
121
+ aggregate: AggregateFunction | None = None
122
+ alias: str | None = None
123
+ case: CaseExpr | None = None
124
+
125
+
126
+ @dataclass
127
+ class SelectQuery:
128
+ """Represents a parsed SELECT query."""
129
+
130
+ columns: list[SelectColumn]
131
+ table: str
132
+ where: WhereClause | None = None
133
+ order_by: list[OrderByItem] = field(default_factory=list)
134
+ group_by: list[str] = field(default_factory=list)
135
+ having: WhereClause | None = None
136
+ limit: int | None = None
137
+ joins: list[JoinClause] = field(default_factory=list)
138
+ distinct: bool = False
139
+
140
+
141
+ @dataclass
142
+ class InsertQuery:
143
+ """Represents a parsed INSERT query."""
144
+
145
+ table: str
146
+ columns: list[str]
147
+ values: list[Any]
148
+
149
+
150
+ @dataclass
151
+ class UpdateQuery:
152
+ """Represents a parsed UPDATE query."""
153
+
154
+ table: str
155
+ set_clause: dict[str, Any]
156
+ where: WhereClause | None = None
157
+
158
+
159
+ @dataclass
160
+ class DeleteQuery:
161
+ """Represents a parsed DELETE query."""
162
+
163
+ table: str
164
+ where: WhereClause | None = None
165
+
166
+
167
+ @dataclass
168
+ class ColumnDef:
169
+ """Represents a column definition in CREATE TABLE."""
170
+
171
+ name: str
172
+ type: ColumnType
173
+ primary_key: bool = False
174
+ nullable: bool = True
175
+
176
+
177
+ @dataclass
178
+ class CreateTableQuery:
179
+ """Represents a parsed CREATE TABLE query."""
180
+
181
+ table: str
182
+ columns: list[ColumnDef]
183
+
184
+
185
+ @dataclass
186
+ class DropTableQuery:
187
+ """Represents a parsed DROP TABLE query."""
188
+
189
+ table: str
190
+
191
+
192
+ @dataclass
193
+ class BeginQuery:
194
+ """Represents a parsed BEGIN statement."""
195
+
196
+
197
+ @dataclass
198
+ class CommitQuery:
199
+ """Represents a parsed COMMIT statement."""
200
+
201
+
202
+ @dataclass
203
+ class RollbackQuery:
204
+ """Represents a parsed ROLLBACK statement."""
205
+
206
+
207
+ class Lexer:
208
+ """Tokenizes SQL queries."""
209
+
210
+ KEYWORDS = {
211
+ 'SELECT': TokenType.SELECT,
212
+ 'FROM': TokenType.FROM,
213
+ 'WHERE': TokenType.WHERE,
214
+ 'INSERT': TokenType.INSERT,
215
+ 'INTO': TokenType.INTO,
216
+ 'VALUES': TokenType.VALUES,
217
+ 'UPDATE': TokenType.UPDATE,
218
+ 'SET': TokenType.SET,
219
+ 'DELETE': TokenType.DELETE,
220
+ 'CREATE': TokenType.CREATE,
221
+ 'TABLE': TokenType.TABLE,
222
+ 'DROP': TokenType.DROP,
223
+ 'ORDER': TokenType.ORDER,
224
+ 'BY': TokenType.BY,
225
+ 'GROUP': TokenType.GROUP,
226
+ 'JOIN': TokenType.JOIN,
227
+ 'INNER': TokenType.INNER,
228
+ 'LEFT': TokenType.LEFT,
229
+ 'RIGHT': TokenType.RIGHT,
230
+ 'ON': TokenType.ON,
231
+ 'AND': TokenType.AND,
232
+ 'OR': TokenType.OR,
233
+ 'NOT': TokenType.NOT,
234
+ 'IN': TokenType.IN,
235
+ 'LIKE': TokenType.LIKE,
236
+ 'ASC': TokenType.ASC,
237
+ 'DESC': TokenType.DESC,
238
+ 'PRIMARY': TokenType.PRIMARY,
239
+ 'KEY': TokenType.KEY,
240
+ 'NULL': TokenType.NULL,
241
+ 'IS': TokenType.IS,
242
+ 'LIMIT': TokenType.LIMIT,
243
+ 'AS': TokenType.AS,
244
+ 'DISTINCT': TokenType.DISTINCT,
245
+ 'HAVING': TokenType.HAVING,
246
+ 'CASE': TokenType.CASE,
247
+ 'WHEN': TokenType.WHEN,
248
+ 'THEN': TokenType.THEN,
249
+ 'ELSE': TokenType.ELSE,
250
+ 'END': TokenType.END,
251
+ 'BEGIN': TokenType.BEGIN,
252
+ 'COMMIT': TokenType.COMMIT,
253
+ 'ROLLBACK': TokenType.ROLLBACK,
254
+ }
255
+
256
+ def __init__(self, sql: str):
257
+ self.sql = sql
258
+ self.pos = 0
259
+ self.tokens: list[Token] = []
260
+
261
+ def tokenize(self) -> list[Token]:
262
+ """Tokenize the SQL string."""
263
+ while self.pos < len(self.sql):
264
+ self._skip_whitespace()
265
+ if self.pos >= len(self.sql):
266
+ break
267
+
268
+ char = self.sql[self.pos]
269
+
270
+ # String literal
271
+ if char in ('"', "'"):
272
+ self._read_string(char)
273
+ # Number
274
+ elif char.isdigit() or (char == '-' and self.pos + 1 < len(self.sql) and self.sql[self.pos + 1].isdigit()):
275
+ self._read_number()
276
+ # Identifier or keyword
277
+ elif char.isalpha() or char == '_':
278
+ self._read_identifier()
279
+ # Operators and punctuation
280
+ elif char == '=':
281
+ self.tokens.append(Token(TokenType.EQUALS, '=', self.pos))
282
+ self.pos += 1
283
+ elif char == '!':
284
+ if self.pos + 1 < len(self.sql) and self.sql[self.pos + 1] == '=':
285
+ self.tokens.append(Token(TokenType.NOT_EQUALS, '!=', self.pos))
286
+ self.pos += 2
287
+ else:
288
+ raise SyntaxError_("Unexpected character '!'", self.pos)
289
+ elif char == '>':
290
+ if self.pos + 1 < len(self.sql) and self.sql[self.pos + 1] == '=':
291
+ self.tokens.append(Token(TokenType.GREATER_EQUAL, '>=', self.pos))
292
+ self.pos += 2
293
+ else:
294
+ self.tokens.append(Token(TokenType.GREATER, '>', self.pos))
295
+ self.pos += 1
296
+ elif char == '<':
297
+ if self.pos + 1 < len(self.sql) and self.sql[self.pos + 1] == '=':
298
+ self.tokens.append(Token(TokenType.LESS_EQUAL, '<=', self.pos))
299
+ self.pos += 2
300
+ else:
301
+ self.tokens.append(Token(TokenType.LESS, '<', self.pos))
302
+ self.pos += 1
303
+ elif char == ',':
304
+ self.tokens.append(Token(TokenType.COMMA, ',', self.pos))
305
+ self.pos += 1
306
+ elif char == '.':
307
+ self.tokens.append(Token(TokenType.DOT, '.', self.pos))
308
+ self.pos += 1
309
+ elif char == '(':
310
+ self.tokens.append(Token(TokenType.LPAREN, '(', self.pos))
311
+ self.pos += 1
312
+ elif char == ')':
313
+ self.tokens.append(Token(TokenType.RPAREN, ')', self.pos))
314
+ self.pos += 1
315
+ elif char == ';':
316
+ self.tokens.append(Token(TokenType.SEMICOLON, ';', self.pos))
317
+ self.pos += 1
318
+ elif char == '*':
319
+ self.tokens.append(Token(TokenType.STAR, '*', self.pos))
320
+ self.pos += 1
321
+ elif char == '?':
322
+ self.tokens.append(Token(TokenType.PLACEHOLDER, '?', self.pos))
323
+ self.pos += 1
324
+ elif char == '-' and self.pos + 1 < len(self.sql) and self.sql[self.pos + 1] == '-':
325
+ self._skip_line_comment()
326
+ elif char == '/' and self.pos + 1 < len(self.sql) and self.sql[self.pos + 1] == '*':
327
+ self._skip_block_comment()
328
+ else:
329
+ raise SyntaxError_(f"Unexpected character: '{char}'", self.pos)
330
+
331
+ self.tokens.append(Token(TokenType.EOF, None, self.pos))
332
+ return self.tokens
333
+
334
+ def _skip_line_comment(self) -> None:
335
+ """Skip a -- comment through the end of the line."""
336
+ self.pos += 2
337
+ while self.pos < len(self.sql) and self.sql[self.pos] not in '\n\r':
338
+ self.pos += 1
339
+
340
+ def _skip_block_comment(self) -> None:
341
+ """Skip a /* */ comment. An unclosed comment is a syntax error."""
342
+ start = self.pos
343
+ self.pos += 2
344
+ while self.pos + 1 < len(self.sql):
345
+ if self.sql[self.pos] == '*' and self.sql[self.pos + 1] == '/':
346
+ self.pos += 2
347
+ return
348
+ self.pos += 1
349
+ raise SyntaxError_('Unterminated block comment', start)
350
+
351
+ def _skip_whitespace(self):
352
+ """Skip whitespace characters."""
353
+ while self.pos < len(self.sql) and self.sql[self.pos].isspace():
354
+ self.pos += 1
355
+
356
+ def _read_string(self, quote_char: str):
357
+ """Read a string literal."""
358
+ start = self.pos
359
+ self.pos += 1 # Skip opening quote
360
+ value = []
361
+
362
+ while self.pos < len(self.sql):
363
+ char = self.sql[self.pos]
364
+ if char == quote_char:
365
+ # Check for escaped quote
366
+ if self.pos + 1 < len(self.sql) and self.sql[self.pos + 1] == quote_char:
367
+ value.append(quote_char)
368
+ self.pos += 2
369
+ else:
370
+ self.pos += 1 # Skip closing quote
371
+ break
372
+ elif char == '\\' and self.pos + 1 < len(self.sql):
373
+ # Handle escape sequences
374
+ next_char = self.sql[self.pos + 1]
375
+ if next_char == 'n':
376
+ value.append('\n')
377
+ elif next_char == 't':
378
+ value.append('\t')
379
+ elif next_char == '\\':
380
+ value.append('\\')
381
+ elif next_char == quote_char:
382
+ value.append(quote_char)
383
+ else:
384
+ value.append(next_char)
385
+ self.pos += 2
386
+ else:
387
+ value.append(char)
388
+ self.pos += 1
389
+ else:
390
+ raise SyntaxError_('Unterminated string literal', start)
391
+
392
+ self.tokens.append(Token(TokenType.STRING_LITERAL, ''.join(value), start))
393
+
394
+ def _read_number(self):
395
+ """Read a numeric literal."""
396
+ start = self.pos
397
+ value = []
398
+
399
+ if self.sql[self.pos] == '-':
400
+ value.append('-')
401
+ self.pos += 1
402
+
403
+ while self.pos < len(self.sql) and self.sql[self.pos].isdigit():
404
+ value.append(self.sql[self.pos])
405
+ self.pos += 1
406
+
407
+ # Check for decimal
408
+ if self.pos < len(self.sql) and self.sql[self.pos] == '.':
409
+ value.append('.')
410
+ self.pos += 1
411
+ while self.pos < len(self.sql) and self.sql[self.pos].isdigit():
412
+ value.append(self.sql[self.pos])
413
+ self.pos += 1
414
+ self.tokens.append(Token(TokenType.FLOAT_LITERAL, float(''.join(value)), start))
415
+ else:
416
+ self.tokens.append(Token(TokenType.INTEGER_LITERAL, int(''.join(value)), start))
417
+
418
+ def _read_identifier(self):
419
+ """Read an identifier or keyword."""
420
+ start = self.pos
421
+ value = []
422
+
423
+ while self.pos < len(self.sql) and (self.sql[self.pos].isalnum() or self.sql[self.pos] == '_'):
424
+ value.append(self.sql[self.pos])
425
+ self.pos += 1
426
+
427
+ identifier = ''.join(value)
428
+ upper = identifier.upper()
429
+
430
+ # Check for keywords
431
+ if upper in self.KEYWORDS:
432
+ self.tokens.append(Token(self.KEYWORDS[upper], upper, start))
433
+ # Check for boolean literals
434
+ elif upper == 'TRUE':
435
+ self.tokens.append(Token(TokenType.BOOLEAN_LITERAL, True, start))
436
+ elif upper == 'FALSE':
437
+ self.tokens.append(Token(TokenType.BOOLEAN_LITERAL, False, start))
438
+ else:
439
+ self.tokens.append(Token(TokenType.IDENTIFIER, identifier, start))
440
+
441
+
442
+ class Parser:
443
+ """Parses tokenized SQL queries."""
444
+
445
+ def __init__(self, tokens: list[Token]):
446
+ self.tokens = tokens
447
+ self.pos = 0
448
+ self._paren_depth = 0
449
+
450
+ def parse(
451
+ self,
452
+ ) -> (
453
+ SelectQuery
454
+ | InsertQuery
455
+ | UpdateQuery
456
+ | DeleteQuery
457
+ | CreateTableQuery
458
+ | DropTableQuery
459
+ | BeginQuery
460
+ | CommitQuery
461
+ | RollbackQuery
462
+ ):
463
+ """Parse the token stream into a query object."""
464
+ token = self._current()
465
+
466
+ if token.type == TokenType.SELECT:
467
+ return self._parse_select()
468
+ elif token.type == TokenType.INSERT:
469
+ return self._parse_insert()
470
+ elif token.type == TokenType.UPDATE:
471
+ return self._parse_update()
472
+ elif token.type == TokenType.DELETE:
473
+ return self._parse_delete()
474
+ elif token.type == TokenType.CREATE:
475
+ return self._parse_create()
476
+ elif token.type == TokenType.DROP:
477
+ return self._parse_drop()
478
+ elif token.type == TokenType.BEGIN:
479
+ return self._parse_begin()
480
+ elif token.type == TokenType.COMMIT:
481
+ return self._parse_commit()
482
+ elif token.type == TokenType.ROLLBACK:
483
+ return self._parse_rollback()
484
+ else:
485
+ raise SyntaxError_(f'Unexpected token: {token.value}', token.position)
486
+
487
+ def _current(self) -> Token:
488
+ """Get the current token."""
489
+ return self.tokens[self.pos] if self.pos < len(self.tokens) else self.tokens[-1]
490
+
491
+ def _peek(self, offset: int = 1) -> Token:
492
+ """Peek at a future token."""
493
+ pos = self.pos + offset
494
+ return self.tokens[pos] if pos < len(self.tokens) else self.tokens[-1]
495
+
496
+ def _advance(self) -> Token:
497
+ """Advance to the next token and return the previous one."""
498
+ token = self._current()
499
+ self.pos += 1
500
+ return token
501
+
502
+ def _expect(self, token_type: TokenType) -> Token:
503
+ """Expect a specific token type."""
504
+ token = self._current()
505
+ if token.type != token_type:
506
+ raise SyntaxError_(f'Expected {token_type.name}, got {token.type.name}', token.position)
507
+ return self._advance()
508
+
509
+ def _match(self, *token_types: TokenType) -> bool:
510
+ """Check if current token matches any of the given types."""
511
+ return self._current().type in token_types
512
+
513
+ def _expect_end(self) -> None:
514
+ """Consume an optional semicolon, then require end of input."""
515
+ if self._match(TokenType.SEMICOLON):
516
+ self._advance()
517
+ token = self._current()
518
+ if token.type != TokenType.EOF:
519
+ raise SyntaxError_(f'Unexpected token: {token.value}', token.position)
520
+
521
+ def _parse_select(self) -> SelectQuery:
522
+ """Parse a SELECT query."""
523
+ self._expect(TokenType.SELECT)
524
+
525
+ distinct = False
526
+ if self._match(TokenType.DISTINCT):
527
+ self._advance()
528
+ distinct = True
529
+
530
+ # Parse columns
531
+ columns = self._parse_select_columns()
532
+
533
+ # FROM clause
534
+ self._expect(TokenType.FROM)
535
+ table_token = self._expect(TokenType.IDENTIFIER)
536
+ table = table_token.value
537
+
538
+ # JOIN clauses
539
+ joins = []
540
+ while self._match(TokenType.JOIN, TokenType.INNER, TokenType.LEFT, TokenType.RIGHT):
541
+ joins.append(self._parse_join())
542
+
543
+ # WHERE clause
544
+ where = None
545
+ if self._match(TokenType.WHERE):
546
+ self._advance()
547
+ where = self._parse_where()
548
+
549
+ # GROUP BY clause
550
+ group_by = []
551
+ if self._match(TokenType.GROUP):
552
+ self._advance()
553
+ self._expect(TokenType.BY)
554
+ group_by = self._parse_group_by()
555
+
556
+ # HAVING clause
557
+ having = None
558
+ if self._match(TokenType.HAVING):
559
+ self._advance()
560
+ having = self._parse_where()
561
+
562
+ # ORDER BY clause
563
+ order_by = []
564
+ if self._match(TokenType.ORDER):
565
+ self._advance()
566
+ self._expect(TokenType.BY)
567
+ order_by = self._parse_order_by()
568
+
569
+ # LIMIT clause
570
+ limit = None
571
+ if self._match(TokenType.LIMIT):
572
+ self._advance()
573
+ limit_token = self._expect(TokenType.INTEGER_LITERAL)
574
+ limit = limit_token.value
575
+
576
+ self._expect_end()
577
+
578
+ return SelectQuery(
579
+ columns=columns,
580
+ table=table,
581
+ where=where,
582
+ order_by=order_by,
583
+ group_by=group_by,
584
+ having=having,
585
+ limit=limit,
586
+ joins=joins,
587
+ distinct=distinct,
588
+ )
589
+
590
+ def _parse_select_columns(self) -> list[SelectColumn]:
591
+ """Parse SELECT column list."""
592
+ columns = []
593
+
594
+ while True:
595
+ col = self._parse_select_column()
596
+ columns.append(col)
597
+
598
+ if not self._match(TokenType.COMMA):
599
+ break
600
+ self._advance()
601
+
602
+ return columns
603
+
604
+ def _parse_select_column(self) -> SelectColumn:
605
+ """Parse a single SELECT column."""
606
+ if self._match(TokenType.CASE):
607
+ return self._parse_case_column()
608
+
609
+ # Check for aggregate functions
610
+ if self._match(TokenType.IDENTIFIER):
611
+ name = self._current().value.upper()
612
+ if name in ('COUNT', 'SUM', 'AVG', 'MIN', 'MAX'):
613
+ return self._parse_aggregate_column()
614
+
615
+ # Check for *
616
+ if self._match(TokenType.STAR):
617
+ self._advance()
618
+ return SelectColumn(name='*')
619
+
620
+ # Check for table.column or just column
621
+ table_alias = None
622
+ col_name = self._expect(TokenType.IDENTIFIER).value
623
+
624
+ if self._match(TokenType.DOT):
625
+ self._advance()
626
+ if self._match(TokenType.STAR):
627
+ self._advance()
628
+ return SelectColumn(name='*', table_alias=col_name)
629
+ table_alias = col_name
630
+ col_name = self._expect(TokenType.IDENTIFIER).value
631
+
632
+ alias = None
633
+ if self._match(TokenType.AS):
634
+ self._advance()
635
+ alias = self._expect(TokenType.IDENTIFIER).value
636
+ elif self._match(TokenType.IDENTIFIER):
637
+ alias = self._advance().value
638
+
639
+ return SelectColumn(name=col_name, table_alias=table_alias, alias=alias)
640
+
641
+ def _parse_aggregate_column(self) -> SelectColumn:
642
+ """Parse an aggregate function column."""
643
+ func_name = self._advance().value.upper()
644
+ self._expect(TokenType.LPAREN)
645
+
646
+ table_alias = None
647
+ if self._match(TokenType.STAR):
648
+ self._advance()
649
+ col_name = '*'
650
+ else:
651
+ col_name = self._expect(TokenType.IDENTIFIER).value
652
+ # Check for table.column syntax
653
+ if self._match(TokenType.DOT):
654
+ self._advance()
655
+ table_alias = col_name
656
+ col_name = self._expect(TokenType.IDENTIFIER).value
657
+
658
+ self._expect(TokenType.RPAREN)
659
+
660
+ alias = None
661
+ if self._match(TokenType.AS):
662
+ self._advance()
663
+ alias = self._expect(TokenType.IDENTIFIER).value
664
+ elif self._match(TokenType.IDENTIFIER):
665
+ alias = self._advance().value
666
+
667
+ return SelectColumn(
668
+ name=col_name,
669
+ table_alias=table_alias,
670
+ aggregate=AggregateFunction(function=func_name, column=col_name, alias=alias),
671
+ alias=alias,
672
+ )
673
+
674
+ def _parse_case_column(self) -> SelectColumn:
675
+ """Parse CASE WHEN ... THEN ... [ELSE ...] END [AS alias]."""
676
+ self._expect(TokenType.CASE)
677
+ if not self._match(TokenType.WHEN):
678
+ token = self._current()
679
+ raise SyntaxError_(f'Expected WHEN, got {token.type.name}', token.position)
680
+
681
+ whens: list[CaseWhen] = []
682
+ while self._match(TokenType.WHEN):
683
+ self._advance()
684
+ condition = self._parse_or()
685
+ self._expect(TokenType.THEN)
686
+ whens.append(CaseWhen(condition=condition, result=self._parse_case_result()))
687
+
688
+ else_result = None
689
+ if self._match(TokenType.ELSE):
690
+ self._advance()
691
+ else_result = self._parse_case_result()
692
+ self._expect(TokenType.END)
693
+
694
+ alias = None
695
+ if self._match(TokenType.AS):
696
+ self._advance()
697
+ alias = self._expect(TokenType.IDENTIFIER).value
698
+ elif self._match(TokenType.IDENTIFIER):
699
+ alias = self._advance().value
700
+
701
+ return SelectColumn(name='CASE', alias=alias, case=CaseExpr(whens=whens, else_result=else_result))
702
+
703
+ def _parse_case_result(self) -> CaseResult:
704
+ """Parse a THEN or ELSE result: literal, parameter, or column."""
705
+ token = self._current()
706
+ if token.type in (
707
+ TokenType.STRING_LITERAL,
708
+ TokenType.INTEGER_LITERAL,
709
+ TokenType.FLOAT_LITERAL,
710
+ TokenType.BOOLEAN_LITERAL,
711
+ TokenType.NULL,
712
+ TokenType.PLACEHOLDER,
713
+ ):
714
+ return CaseResult(literal=self._parse_value())
715
+ if token.type == TokenType.IDENTIFIER:
716
+ name = self._advance().value
717
+ table_alias = None
718
+ if self._match(TokenType.DOT):
719
+ self._advance()
720
+ table_alias = name
721
+ name = self._expect(TokenType.IDENTIFIER).value
722
+ return CaseResult(column=name, table_alias=table_alias)
723
+ raise SyntaxError_(f'Expected literal value, got {token.type.name}', token.position)
724
+
725
+ def _parse_join(self) -> JoinClause:
726
+ """Parse a JOIN clause."""
727
+ join_type = 'INNER'
728
+
729
+ if self._match(TokenType.LEFT):
730
+ join_type = 'LEFT'
731
+ self._advance()
732
+ elif self._match(TokenType.RIGHT):
733
+ join_type = 'RIGHT'
734
+ self._advance()
735
+ elif self._match(TokenType.INNER):
736
+ self._advance()
737
+
738
+ self._expect(TokenType.JOIN)
739
+ right_table = self._expect(TokenType.IDENTIFIER).value
740
+
741
+ self._expect(TokenType.ON)
742
+
743
+ # Parse join condition: left_col = right_col
744
+ left_col = self._expect(TokenType.IDENTIFIER).value
745
+ left_table = None
746
+ if self._match(TokenType.DOT):
747
+ self._advance()
748
+ left_table = left_col
749
+ left_col = self._expect(TokenType.IDENTIFIER).value
750
+
751
+ self._expect(TokenType.EQUALS)
752
+
753
+ right_col = self._expect(TokenType.IDENTIFIER).value
754
+ right_table_alias = None
755
+ if self._match(TokenType.DOT):
756
+ self._advance()
757
+ right_table_alias = right_col
758
+ right_col = self._expect(TokenType.IDENTIFIER).value
759
+
760
+ return JoinClause(
761
+ table=right_table,
762
+ left_column=left_col,
763
+ right_column=right_col,
764
+ join_type=join_type,
765
+ left_table=left_table,
766
+ right_table=right_table_alias,
767
+ )
768
+
769
+ def _parse_where(self) -> WhereClause:
770
+ """Parse a WHERE clause with AND binding tighter than OR."""
771
+ return self._parse_or()
772
+
773
+ def _parse_or(self) -> WhereClause:
774
+ """Parse OR-separated AND groups."""
775
+ conditions = [self._parse_and()]
776
+ while self._match(TokenType.OR):
777
+ self._advance()
778
+ conditions.append(self._parse_and())
779
+ if len(conditions) == 1:
780
+ if isinstance(conditions[0], WhereClause):
781
+ return conditions[0]
782
+ return WhereClause(conditions=conditions, operator='AND')
783
+ return WhereClause(conditions=conditions, operator='OR')
784
+
785
+ def _parse_and(self) -> Condition | WhereClause:
786
+ """Parse AND-separated predicates."""
787
+ conditions = [self._parse_predicate()]
788
+ while self._match(TokenType.AND):
789
+ self._advance()
790
+ conditions.append(self._parse_predicate())
791
+ if len(conditions) == 1:
792
+ return conditions[0]
793
+ return WhereClause(conditions=conditions, operator='AND')
794
+
795
+ def _parse_predicate(self) -> Condition | WhereClause:
796
+ """Parse a parenthesized clause or a single condition."""
797
+ if self._match(TokenType.LPAREN):
798
+ # Each pair recurses through OR and AND. Past this depth the
799
+ # call stack raises RecursionError instead of a SQL error.
800
+ if self._paren_depth >= 100:
801
+ token = self.tokens[self.pos]
802
+ raise SyntaxError_('Parentheses nested more than 100 levels', token.position)
803
+ self._advance()
804
+ self._paren_depth += 1
805
+ try:
806
+ clause = self._parse_or()
807
+ finally:
808
+ self._paren_depth -= 1
809
+ self._expect(TokenType.RPAREN)
810
+ return clause
811
+ return self._parse_condition()
812
+
813
+ def _parse_condition(self) -> Condition:
814
+ """Parse a single condition."""
815
+ negated = False
816
+ if self._match(TokenType.NOT):
817
+ self._advance()
818
+ negated = True
819
+
820
+ # Parse column (possibly with table prefix or aggregate syntax)
821
+ table_alias = None
822
+ column = self._expect(TokenType.IDENTIFIER).value
823
+
824
+ if column.upper() in ('COUNT', 'SUM', 'AVG', 'MIN', 'MAX') and self._match(TokenType.LPAREN):
825
+ func = column.upper()
826
+ self._advance()
827
+ if self._match(TokenType.STAR):
828
+ self._advance()
829
+ agg_col = '*'
830
+ else:
831
+ agg_col = self._expect(TokenType.IDENTIFIER).value
832
+ if self._match(TokenType.DOT):
833
+ self._advance()
834
+ table_alias = agg_col
835
+ agg_col = self._expect(TokenType.IDENTIFIER).value
836
+ self._expect(TokenType.RPAREN)
837
+ shown = f'{table_alias}.{agg_col}' if table_alias else agg_col
838
+ column = f'{func}({shown})'
839
+ elif self._match(TokenType.DOT):
840
+ self._advance()
841
+ table_alias = column
842
+ column = self._expect(TokenType.IDENTIFIER).value
843
+
844
+ # Parse operator
845
+ if self._match(TokenType.IS):
846
+ self._advance()
847
+ if self._match(TokenType.NOT):
848
+ self._advance()
849
+ self._expect(TokenType.NULL)
850
+ return Condition(
851
+ column=column,
852
+ operator='IS NOT NULL',
853
+ value=None,
854
+ table_alias=table_alias,
855
+ negated=negated,
856
+ )
857
+ self._expect(TokenType.NULL)
858
+ return Condition(
859
+ column=column,
860
+ operator='IS NULL',
861
+ value=None,
862
+ table_alias=table_alias,
863
+ negated=negated,
864
+ )
865
+
866
+ if self._match(TokenType.EQUALS):
867
+ op = '='
868
+ self._advance()
869
+ elif self._match(TokenType.NOT_EQUALS):
870
+ op = '!='
871
+ self._advance()
872
+ elif self._match(TokenType.GREATER):
873
+ op = '>'
874
+ self._advance()
875
+ elif self._match(TokenType.GREATER_EQUAL):
876
+ op = '>='
877
+ self._advance()
878
+ elif self._match(TokenType.LESS):
879
+ op = '<'
880
+ self._advance()
881
+ elif self._match(TokenType.LESS_EQUAL):
882
+ op = '<='
883
+ self._advance()
884
+ elif self._match(TokenType.LIKE):
885
+ op = 'LIKE'
886
+ self._advance()
887
+ elif self._match(TokenType.IN):
888
+ op = 'IN'
889
+ self._advance()
890
+ else:
891
+ raise SyntaxError_(
892
+ f'Expected comparison operator, got {self._current().type.name}', self._current().position
893
+ )
894
+
895
+ # Parse value
896
+ if op == 'IN':
897
+ self._expect(TokenType.LPAREN)
898
+ values = []
899
+ while True:
900
+ values.append(self._parse_value())
901
+ if not self._match(TokenType.COMMA):
902
+ break
903
+ self._advance()
904
+ self._expect(TokenType.RPAREN)
905
+ value = values
906
+ else:
907
+ value = self._parse_value()
908
+
909
+ return Condition(column=column, operator=op, value=value, table_alias=table_alias, negated=negated)
910
+
911
+ def _parse_value(self) -> Any:
912
+ """Parse a literal value."""
913
+ token = self._current()
914
+
915
+ if (
916
+ token.type == TokenType.STRING_LITERAL
917
+ or token.type == TokenType.INTEGER_LITERAL
918
+ or token.type == TokenType.FLOAT_LITERAL
919
+ or token.type == TokenType.BOOLEAN_LITERAL
920
+ ):
921
+ self._advance()
922
+ return token.value
923
+ elif token.type == TokenType.NULL:
924
+ self._advance()
925
+ return None
926
+ elif token.type == TokenType.PLACEHOLDER:
927
+ self._advance()
928
+ return ParamPlaceholder()
929
+ else:
930
+ raise SyntaxError_(f'Expected literal value, got {token.type.name}', token.position)
931
+
932
+ def _parse_group_by(self) -> list[str]:
933
+ """Parse GROUP BY columns. A qualifier is kept as 'table.column'."""
934
+ columns = []
935
+ while True:
936
+ name = self._expect(TokenType.IDENTIFIER).value
937
+ if self._match(TokenType.DOT):
938
+ self._advance()
939
+ name = f'{name}.{self._expect(TokenType.IDENTIFIER).value}'
940
+ columns.append(name)
941
+ if not self._match(TokenType.COMMA):
942
+ break
943
+ self._advance()
944
+ return columns
945
+
946
+ def _parse_order_by(self) -> list[OrderByItem]:
947
+ """Parse ORDER BY clause."""
948
+ items = []
949
+ while True:
950
+ column = self._expect(TokenType.IDENTIFIER).value
951
+ table_alias = None
952
+
953
+ if self._match(TokenType.DOT):
954
+ self._advance()
955
+ table_alias = column
956
+ column = self._expect(TokenType.IDENTIFIER).value
957
+
958
+ direction = 'ASC'
959
+ if self._match(TokenType.ASC):
960
+ self._advance()
961
+ elif self._match(TokenType.DESC):
962
+ self._advance()
963
+ direction = 'DESC'
964
+
965
+ items.append(OrderByItem(column=column, direction=direction, table_alias=table_alias))
966
+
967
+ if not self._match(TokenType.COMMA):
968
+ break
969
+ self._advance()
970
+
971
+ return items
972
+
973
+ def _parse_insert(self) -> InsertQuery:
974
+ """Parse an INSERT query."""
975
+ self._expect(TokenType.INSERT)
976
+ self._expect(TokenType.INTO)
977
+
978
+ table = self._expect(TokenType.IDENTIFIER).value
979
+
980
+ # Parse columns
981
+ self._expect(TokenType.LPAREN)
982
+ columns = []
983
+ while True:
984
+ columns.append(self._expect(TokenType.IDENTIFIER).value)
985
+ if not self._match(TokenType.COMMA):
986
+ break
987
+ self._advance()
988
+ self._expect(TokenType.RPAREN)
989
+
990
+ # Parse VALUES
991
+ self._expect(TokenType.VALUES)
992
+ self._expect(TokenType.LPAREN)
993
+ values = []
994
+ while True:
995
+ values.append(self._parse_value())
996
+ if not self._match(TokenType.COMMA):
997
+ break
998
+ self._advance()
999
+ self._expect(TokenType.RPAREN)
1000
+
1001
+ self._expect_end()
1002
+
1003
+ return InsertQuery(table=table, columns=columns, values=values)
1004
+
1005
+ def _parse_update(self) -> UpdateQuery:
1006
+ """Parse an UPDATE query."""
1007
+ self._expect(TokenType.UPDATE)
1008
+ table = self._expect(TokenType.IDENTIFIER).value
1009
+
1010
+ # Parse SET clause
1011
+ self._expect(TokenType.SET)
1012
+ set_clause = {}
1013
+ while True:
1014
+ col = self._expect(TokenType.IDENTIFIER).value
1015
+ self._expect(TokenType.EQUALS)
1016
+ val = self._parse_value()
1017
+ if col in set_clause:
1018
+ raise InvalidQueryError(f"Duplicate column name '{col}'")
1019
+ set_clause[col] = val
1020
+
1021
+ if not self._match(TokenType.COMMA):
1022
+ break
1023
+ self._advance()
1024
+
1025
+ # Parse WHERE clause
1026
+ where = None
1027
+ if self._match(TokenType.WHERE):
1028
+ self._advance()
1029
+ where = self._parse_where()
1030
+
1031
+ self._expect_end()
1032
+
1033
+ return UpdateQuery(table=table, set_clause=set_clause, where=where)
1034
+
1035
+ def _parse_delete(self) -> DeleteQuery:
1036
+ """Parse a DELETE query."""
1037
+ self._expect(TokenType.DELETE)
1038
+ self._expect(TokenType.FROM)
1039
+
1040
+ table = self._expect(TokenType.IDENTIFIER).value
1041
+
1042
+ # Parse WHERE clause
1043
+ where = None
1044
+ if self._match(TokenType.WHERE):
1045
+ self._advance()
1046
+ where = self._parse_where()
1047
+
1048
+ self._expect_end()
1049
+
1050
+ return DeleteQuery(table=table, where=where)
1051
+
1052
+ def _parse_create(self) -> CreateTableQuery:
1053
+ """Parse a CREATE TABLE query."""
1054
+ self._expect(TokenType.CREATE)
1055
+ self._expect(TokenType.TABLE)
1056
+
1057
+ table = self._expect(TokenType.IDENTIFIER).value
1058
+
1059
+ # Parse column definitions
1060
+ self._expect(TokenType.LPAREN)
1061
+ columns = []
1062
+ while True:
1063
+ col = self._parse_column_def()
1064
+ columns.append(col)
1065
+
1066
+ if not self._match(TokenType.COMMA):
1067
+ break
1068
+ self._advance()
1069
+ self._expect(TokenType.RPAREN)
1070
+
1071
+ self._expect_end()
1072
+
1073
+ return CreateTableQuery(table=table, columns=columns)
1074
+
1075
+ def _parse_column_def(self) -> ColumnDef:
1076
+ """Parse a column definition."""
1077
+ name = self._expect(TokenType.IDENTIFIER).value
1078
+ type_str = self._expect(TokenType.IDENTIFIER).value
1079
+ col_type = ColumnType.from_string(type_str)
1080
+
1081
+ primary_key = False
1082
+ nullable = True
1083
+
1084
+ # Check for PRIMARY KEY
1085
+ if self._match(TokenType.PRIMARY):
1086
+ self._advance()
1087
+ self._expect(TokenType.KEY)
1088
+ primary_key = True
1089
+ nullable = False
1090
+
1091
+ # Check for NULL/NOT NULL
1092
+ if self._match(TokenType.NOT):
1093
+ self._advance()
1094
+ self._expect(TokenType.NULL)
1095
+ nullable = False
1096
+ elif self._match(TokenType.NULL):
1097
+ self._advance()
1098
+ if primary_key:
1099
+ raise InvalidQueryError(f"PRIMARY KEY column '{name}' cannot be NULL")
1100
+ nullable = True
1101
+
1102
+ return ColumnDef(name=name, type=col_type, primary_key=primary_key, nullable=nullable)
1103
+
1104
+ def _parse_drop(self) -> DropTableQuery:
1105
+ """Parse a DROP TABLE query."""
1106
+ self._expect(TokenType.DROP)
1107
+ self._expect(TokenType.TABLE)
1108
+
1109
+ table = self._expect(TokenType.IDENTIFIER).value
1110
+
1111
+ self._expect_end()
1112
+
1113
+ return DropTableQuery(table=table)
1114
+
1115
+ def _parse_begin(self) -> BeginQuery:
1116
+ """Parse a BEGIN statement."""
1117
+ self._expect(TokenType.BEGIN)
1118
+ self._expect_end()
1119
+ return BeginQuery()
1120
+
1121
+ def _parse_commit(self) -> CommitQuery:
1122
+ """Parse a COMMIT statement."""
1123
+ self._expect(TokenType.COMMIT)
1124
+ self._expect_end()
1125
+ return CommitQuery()
1126
+
1127
+ def _parse_rollback(self) -> RollbackQuery:
1128
+ """Parse a ROLLBACK statement."""
1129
+ self._expect(TokenType.ROLLBACK)
1130
+ self._expect_end()
1131
+ return RollbackQuery()
1132
+
1133
+
1134
+ def parse_sql(
1135
+ sql: str,
1136
+ ) -> (
1137
+ SelectQuery
1138
+ | InsertQuery
1139
+ | UpdateQuery
1140
+ | DeleteQuery
1141
+ | CreateTableQuery
1142
+ | DropTableQuery
1143
+ | BeginQuery
1144
+ | CommitQuery
1145
+ | RollbackQuery
1146
+ ):
1147
+ """Parse a SQL string into a query object."""
1148
+ lexer = Lexer(sql)
1149
+ tokens = lexer.tokenize()
1150
+ parser = Parser(tokens)
1151
+ return parser.parse()
1152
+
1153
+
1154
+ def _count_placeholders_in_value(value: Any) -> int:
1155
+ if isinstance(value, ParamPlaceholder):
1156
+ return 1
1157
+ if isinstance(value, list):
1158
+ return sum(_count_placeholders_in_value(item) for item in value)
1159
+ return 0
1160
+
1161
+
1162
+ def _count_where_placeholders(where: WhereClause | None) -> int:
1163
+ if where is None:
1164
+ return 0
1165
+ total = 0
1166
+ for cond in where.conditions:
1167
+ if isinstance(cond, Condition):
1168
+ total += _count_placeholders_in_value(cond.value)
1169
+ elif isinstance(cond, WhereClause):
1170
+ total += _count_where_placeholders(cond)
1171
+ return total
1172
+
1173
+
1174
+ def _count_query_placeholders(
1175
+ query: (
1176
+ SelectQuery
1177
+ | InsertQuery
1178
+ | UpdateQuery
1179
+ | DeleteQuery
1180
+ | CreateTableQuery
1181
+ | DropTableQuery
1182
+ | BeginQuery
1183
+ | CommitQuery
1184
+ | RollbackQuery
1185
+ ),
1186
+ ) -> int:
1187
+ if isinstance(query, InsertQuery):
1188
+ return sum(_count_placeholders_in_value(value) for value in query.values)
1189
+ if isinstance(query, UpdateQuery):
1190
+ return sum(
1191
+ _count_placeholders_in_value(value) for value in query.set_clause.values()
1192
+ ) + _count_where_placeholders(query.where)
1193
+ if isinstance(query, DeleteQuery):
1194
+ return _count_where_placeholders(query.where)
1195
+ if isinstance(query, SelectQuery):
1196
+ total = _count_where_placeholders(query.where) + _count_where_placeholders(query.having)
1197
+ for col in query.columns:
1198
+ if col.case is not None:
1199
+ total += _count_case_placeholders(col.case)
1200
+ return total
1201
+ return 0
1202
+
1203
+
1204
+ def _count_case_placeholders(expr: CaseExpr) -> int:
1205
+ total = 0
1206
+ for when in expr.whens:
1207
+ total += _count_where_placeholders(when.condition)
1208
+ if when.result.column is None:
1209
+ total += _count_placeholders_in_value(when.result.literal)
1210
+ if expr.else_result is not None and expr.else_result.column is None:
1211
+ total += _count_placeholders_in_value(expr.else_result.literal)
1212
+ return total
1213
+
1214
+
1215
+ def _bind_case(expr: CaseExpr, params: list[Any], index: list[int]) -> None:
1216
+ for when in expr.whens:
1217
+ _bind_where(when.condition, params, index)
1218
+ if when.result.column is None:
1219
+ when.result.literal = _bind_value(when.result.literal, params, index)
1220
+ if expr.else_result is not None and expr.else_result.column is None:
1221
+ expr.else_result.literal = _bind_value(expr.else_result.literal, params, index)
1222
+
1223
+
1224
+ def _bind_value(value: Any, params: list[Any], index: list[int]) -> Any:
1225
+ if isinstance(value, ParamPlaceholder):
1226
+ bound = params[index[0]]
1227
+ index[0] += 1
1228
+ return bound
1229
+ if isinstance(value, list):
1230
+ return [_bind_value(item, params, index) for item in value]
1231
+ return value
1232
+
1233
+
1234
+ def _bind_where(where: WhereClause | None, params: list[Any], index: list[int]) -> None:
1235
+ if where is None:
1236
+ return
1237
+ for cond in where.conditions:
1238
+ if isinstance(cond, Condition):
1239
+ cond.value = _bind_value(cond.value, params, index)
1240
+ elif isinstance(cond, WhereClause):
1241
+ _bind_where(cond, params, index)
1242
+
1243
+
1244
+ def bind_params(
1245
+ query: (
1246
+ SelectQuery
1247
+ | InsertQuery
1248
+ | UpdateQuery
1249
+ | DeleteQuery
1250
+ | CreateTableQuery
1251
+ | DropTableQuery
1252
+ | BeginQuery
1253
+ | CommitQuery
1254
+ | RollbackQuery
1255
+ ),
1256
+ params: Sequence[Any] | None = None,
1257
+ ) -> None:
1258
+ """Replace ParamPlaceholder sentinels left-to-right with bound values."""
1259
+ values = list(params) if params is not None else []
1260
+ expected = _count_query_placeholders(query)
1261
+ if expected != len(values):
1262
+ raise InvalidQueryError(f'expected {expected} parameters, got {len(values)}')
1263
+
1264
+ index = [0]
1265
+ if isinstance(query, InsertQuery):
1266
+ query.values = [_bind_value(value, values, index) for value in query.values]
1267
+ elif isinstance(query, UpdateQuery):
1268
+ for key in query.set_clause:
1269
+ query.set_clause[key] = _bind_value(query.set_clause[key], values, index)
1270
+ _bind_where(query.where, values, index)
1271
+ elif isinstance(query, DeleteQuery):
1272
+ _bind_where(query.where, values, index)
1273
+ elif isinstance(query, SelectQuery):
1274
+ for col in query.columns:
1275
+ if col.case is not None:
1276
+ _bind_case(col.case, values, index)
1277
+ _bind_where(query.where, values, index)
1278
+ _bind_where(query.having, values, index)