sqlleaf 0.0.1__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.
Files changed (111) hide show
  1. sqlleaf/__init__.py +4 -0
  2. sqlleaf/dialects/__init__.py +0 -0
  3. sqlleaf/dialects/plpgsql/__init__.py +0 -0
  4. sqlleaf/dialects/plpgsql/p_classes.py +275 -0
  5. sqlleaf/dialects/plpgsql/p_dialect.py +148 -0
  6. sqlleaf/dialects/plpgsql/p_generator.py +538 -0
  7. sqlleaf/dialects/plpgsql/p_parser.py +1244 -0
  8. sqlleaf/dialects/plpgsql/p_tokenizer.py +41 -0
  9. sqlleaf/exception.py +67 -0
  10. sqlleaf/lineage.py +326 -0
  11. sqlleaf/mappings.py +376 -0
  12. sqlleaf/models/__init__.py +0 -0
  13. sqlleaf/models/context.py +153 -0
  14. sqlleaf/models/hook.py +9 -0
  15. sqlleaf/models/node/__init__.py +27 -0
  16. sqlleaf/models/node/base.py +148 -0
  17. sqlleaf/models/node/column.py +345 -0
  18. sqlleaf/models/node/dynamodb.py +39 -0
  19. sqlleaf/models/node/function.py +33 -0
  20. sqlleaf/models/node/interval.py +21 -0
  21. sqlleaf/models/node/json.py +57 -0
  22. sqlleaf/models/node/literal.py +17 -0
  23. sqlleaf/models/node/null.py +14 -0
  24. sqlleaf/models/node/pivot.py +34 -0
  25. sqlleaf/models/node/program.py +27 -0
  26. sqlleaf/models/node/sequence.py +18 -0
  27. sqlleaf/models/node/star.py +11 -0
  28. sqlleaf/models/node/stream.py +14 -0
  29. sqlleaf/models/node/user_defined_function.py +33 -0
  30. sqlleaf/models/node/var.py +7 -0
  31. sqlleaf/models/node/variable.py +8 -0
  32. sqlleaf/models/node/window.py +22 -0
  33. sqlleaf/models/query/__init__.py +33 -0
  34. sqlleaf/models/query/base.py +248 -0
  35. sqlleaf/models/query/call.py +111 -0
  36. sqlleaf/models/query/copy.py +144 -0
  37. sqlleaf/models/query/ctas.py +61 -0
  38. sqlleaf/models/query/database.py +48 -0
  39. sqlleaf/models/query/delete.py +26 -0
  40. sqlleaf/models/query/execute.py +66 -0
  41. sqlleaf/models/query/holder.py +49 -0
  42. sqlleaf/models/query/insert.py +69 -0
  43. sqlleaf/models/query/merge.py +29 -0
  44. sqlleaf/models/query/multitable_insert.py +42 -0
  45. sqlleaf/models/query/prepare.py +61 -0
  46. sqlleaf/models/query/procedure.py +72 -0
  47. sqlleaf/models/query/put.py +38 -0
  48. sqlleaf/models/query/replace.py +32 -0
  49. sqlleaf/models/query/schema.py +47 -0
  50. sqlleaf/models/query/select.py +27 -0
  51. sqlleaf/models/query/sequence.py +33 -0
  52. sqlleaf/models/query/set.py +74 -0
  53. sqlleaf/models/query/stage.py +78 -0
  54. sqlleaf/models/query/table.py +220 -0
  55. sqlleaf/models/query/trigger.py +42 -0
  56. sqlleaf/models/query/type.py +42 -0
  57. sqlleaf/models/query/unload.py +64 -0
  58. sqlleaf/models/query/update.py +45 -0
  59. sqlleaf/models/query/user_defined_function.py +297 -0
  60. sqlleaf/models/query/values.py +20 -0
  61. sqlleaf/models/query/view.py +41 -0
  62. sqlleaf/path.py +194 -0
  63. sqlleaf/processors/__init__.py +0 -0
  64. sqlleaf/processors/collector/__init__.py +0 -0
  65. sqlleaf/processors/collector/collector.py +827 -0
  66. sqlleaf/processors/collector/substitute.py +396 -0
  67. sqlleaf/processors/generator/__init__.py +0 -0
  68. sqlleaf/processors/generator/dialects/__init__.py +5 -0
  69. sqlleaf/processors/generator/dialects/athena.py +45 -0
  70. sqlleaf/processors/generator/dialects/base.py +572 -0
  71. sqlleaf/processors/generator/dialects/mysql.py +19 -0
  72. sqlleaf/processors/generator/dialects/postgres.py +182 -0
  73. sqlleaf/processors/generator/dialects/redshift.py +192 -0
  74. sqlleaf/processors/generator/dialects/snowflake.py +90 -0
  75. sqlleaf/processors/generator/generator.py +495 -0
  76. sqlleaf/processors/transformer/__init__.py +31 -0
  77. sqlleaf/processors/transformer/base.py +899 -0
  78. sqlleaf/processors/transformer/call.py +9 -0
  79. sqlleaf/processors/transformer/copy.py +63 -0
  80. sqlleaf/processors/transformer/ctas.py +39 -0
  81. sqlleaf/processors/transformer/delete.py +14 -0
  82. sqlleaf/processors/transformer/execute.py +9 -0
  83. sqlleaf/processors/transformer/expressions/__init__.py +11 -0
  84. sqlleaf/processors/transformer/expressions/functional_notation.py +22 -0
  85. sqlleaf/processors/transformer/expressions/row.py +451 -0
  86. sqlleaf/processors/transformer/expressions/values.py +318 -0
  87. sqlleaf/processors/transformer/insert.py +125 -0
  88. sqlleaf/processors/transformer/merge.py +14 -0
  89. sqlleaf/processors/transformer/multitable_insert.py +14 -0
  90. sqlleaf/processors/transformer/replace.py +23 -0
  91. sqlleaf/processors/transformer/transformer.py +141 -0
  92. sqlleaf/processors/transformer/udf.py +235 -0
  93. sqlleaf/processors/transformer/unload.py +30 -0
  94. sqlleaf/processors/transformer/update.py +174 -0
  95. sqlleaf/processors/transformer/values.py +10 -0
  96. sqlleaf/settings/__init__.py +19 -0
  97. sqlleaf/settings/athena.py +23 -0
  98. sqlleaf/settings/base.py +37 -0
  99. sqlleaf/settings/postgres.py +52 -0
  100. sqlleaf/system_tables/load.py +0 -0
  101. sqlleaf/typing.py +93 -0
  102. sqlleaf/util/__init__.py +34 -0
  103. sqlleaf/util/expression.py +282 -0
  104. sqlleaf/util/graph.py +116 -0
  105. sqlleaf/util/helpers.py +42 -0
  106. sqlleaf/util/iterators.py +37 -0
  107. sqlleaf-0.0.1.dist-info/METADATA +46 -0
  108. sqlleaf-0.0.1.dist-info/RECORD +111 -0
  109. sqlleaf-0.0.1.dist-info/WHEEL +5 -0
  110. sqlleaf-0.0.1.dist-info/licenses/LICENSE +21 -0
  111. sqlleaf-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1244 @@
1
+ from __future__ import annotations
2
+ import typing as t
3
+
4
+ from sqlglot.parsers.postgres import PostgresParser
5
+ from sqlglot import exp
6
+
7
+ from sqlleaf.dialects.plpgsql.p_classes import *
8
+ from sqlleaf.dialects.plpgsql.p_tokenizer import TokenType
9
+
10
+
11
+ class PlPgSQLParser(PostgresParser):
12
+ STATEMENT_PARSERS = {
13
+ **PostgresParser.STATEMENT_PARSERS,
14
+ TokenType.BEGIN: lambda self: self._parse_plpgsql_block(),
15
+ TokenType.DECLARE: lambda self: self._parse_plpgsql_block(),
16
+ TokenType.UPDATE: lambda self: self._parse_pgupdate(),
17
+ TokenType.DELETE: lambda self: self._parse_pgdelete(),
18
+ TokenType.CASE: lambda self: self._parse_pgcase(),
19
+ TokenType.RETURN: lambda self: self._parse_pgreturn(),
20
+ TokenType.WHILE: lambda self: self._parse_pgwhile(),
21
+ TokenType.IF: lambda self: self._parse_pgif(),
22
+ TokenType.LOOP: lambda self: self._parse_pgloop(),
23
+ TokenType.FOR: lambda self: self._parse_pgfor(),
24
+ TokenType.FOREACH: lambda self: self._parse_pgforeach(),
25
+ TokenType.EXIT: lambda self: self._parse_pgexit(),
26
+ TokenType.CONTINUE: lambda self: self._parse_pgcontinue(),
27
+ TokenType.RAISE: lambda self: self._parse_pgraise(),
28
+ TokenType.ASSERT: lambda self: self._parse_pgassert(),
29
+ TokenType.OPEN: lambda self: self._parse_pgopen(),
30
+ TokenType.PERFORM: lambda self: self._parse_pgperform(),
31
+ TokenType.FETCH: lambda self: self._parse_pgfetch(),
32
+ TokenType.MOVE: lambda self: self._parse_pgmove(),
33
+ TokenType.CLOSE: lambda self: self._parse_pgclose(),
34
+ TokenType.EXECUTE: lambda self: self._parse_pgexecute(),
35
+ TokenType.GET: lambda self: self._parse_pggetdiagnostics(),
36
+ }
37
+
38
+ # Intercept bare FOUND wherever an expression is allowed and parse it into PGFound
39
+ NO_PAREN_FUNCTION_PARSERS = {
40
+ **PostgresParser.NO_PAREN_FUNCTION_PARSERS,
41
+ "FOUND": lambda self: self.expression(PGFound()),
42
+ }
43
+
44
+ # Class-level constant tables (hoisted from per-call definitions)
45
+ _NAMED_PAIR_OPS: dict[TokenType, type[exp.Expression]] = {
46
+ TokenType.COLON_EQ: exp.PropertyEQ,
47
+ TokenType.EQ: exp.EQ,
48
+ TokenType.FARROW: exp.Kwarg,
49
+ }
50
+
51
+ _RAISE_LEVELS = frozenset({
52
+ "DEBUG",
53
+ "LOG",
54
+ "INFO",
55
+ "NOTICE",
56
+ "WARNING",
57
+ "EXCEPTION",
58
+ })
59
+
60
+ _SIMPLE_FETCH_DIRS = {
61
+ "NEXT": PGNext,
62
+ "PRIOR": PGPrior,
63
+ "FIRST": PGFirst,
64
+ "LAST": PGLast,
65
+ }
66
+
67
+ def _match_expect(self, token_type: TokenType, *, advance: bool = True) -> None:
68
+ if not self._match(token_type, advance=advance):
69
+ self.raise_error(f"Expected token: {token_type.name}")
70
+
71
+ def _match_text_expect(self, text: str, *, advance: bool = True) -> None:
72
+ if not self._match_texts((text,), advance=advance):
73
+ self.raise_error(f"Expected token: {text.upper()}")
74
+
75
+ def _parse_statement(self) -> exp.Expression | None:
76
+ if not self._match(TokenType.LABEL_BEGIN):
77
+ return super()._parse_statement()
78
+
79
+ label = self._parse_id_var(any_token=True)
80
+ if label is None:
81
+ self.raise_error("Expected label identifier after <<")
82
+
83
+ self._match_expect(TokenType.LABEL_END)
84
+
85
+ statement = super()._parse_statement()
86
+ if statement is None or not isinstance(statement, (PGBlock, PGLoop, PGForIn, PGForEach, exp.WhileBlock)):
87
+ self.raise_error("Label can only prefix BEGIN/DECLARE blocks and LOOP/FOR/FOREACH/WHILE statements")
88
+
89
+ statement.set("label", label)
90
+ return statement
91
+
92
+ def _parse_pggetdiagnostics(self) -> PGGetDiagnostics:
93
+ current = False
94
+ stacked = False
95
+ if self._match_texts(("CURRENT",)):
96
+ current = True
97
+ elif self._match_texts(("STACKED",)):
98
+ stacked = True
99
+
100
+ self._match_text_expect("DIAGNOSTICS")
101
+
102
+ pairs: list[exp.Expression] = []
103
+
104
+ def parse_item() -> exp.Expression:
105
+ # Items are represented as identifier-like expressions
106
+ ident = self._parse_id_var(any_token=True)
107
+ if ident is None:
108
+ self.raise_error("Expected diagnostics item after operator")
109
+ return ident
110
+
111
+ # Parse one-or-more pairs: <var> {:=|=} <item> [, ...]
112
+ while True:
113
+ var = self._parse_id_var(any_token=True)
114
+ if var is None:
115
+ self.raise_error(
116
+ "Expected variable after DIAGNOSTICS"
117
+ if not pairs
118
+ else "Expected variable after comma in GET DIAGNOSTICS"
119
+ )
120
+
121
+ pair = self._parse_named_pair(
122
+ var,
123
+ allowed_ops={TokenType.EQ, TokenType.COLON_EQ},
124
+ rhs_parser=parse_item,
125
+ )
126
+ if pair is None:
127
+ self.raise_error("Expected assignment operator (= or :=) after variable")
128
+ pairs.append(pair)
129
+
130
+ if not self._match(TokenType.COMMA):
131
+ break
132
+
133
+ return self.expression(PGGetDiagnostics(current=current, stacked=stacked, expressions=pairs))
134
+
135
+ def _parse_named_pair(
136
+ self,
137
+ name_expr: exp.Expression,
138
+ allowed_ops: set[TokenType],
139
+ rhs_parser: t.Callable[[], exp.Expression] | None = None,
140
+ ) -> exp.PropertyEQ | exp.EQ | exp.Kwarg | None:
141
+ """
142
+ Parse a named-argument style pair following a name expression.
143
+ """
144
+ rhsp = rhs_parser or self._parse_expression
145
+
146
+ # Filter to the allowed operators for this context
147
+ candidates = tuple(t for t in self._NAMED_PAIR_OPS.keys() if t in allowed_ops)
148
+ if not candidates:
149
+ return None
150
+
151
+ if not self._match_set(candidates, advance=False):
152
+ return None
153
+
154
+ # Consume the operator and build the corresponding expression
155
+ op = self._curr.token_type
156
+ self._advance()
157
+ klass = self._NAMED_PAIR_OPS[op]
158
+ return self.expression(klass(this=name_expr, expression=rhsp()))
159
+
160
+ def _parse_statement_body(
161
+ self,
162
+ *,
163
+ stop_texts: tuple[str, ...] = (),
164
+ stop_tokens: tuple[TokenType, ...] = (),
165
+ error_msg: str = "Invalid expression / Unexpected token",
166
+ strict: bool = True,
167
+ parse_one: t.Callable[[], exp.Expression | None] | None = None,
168
+ ) -> list[exp.Expression]:
169
+ """
170
+ Parse a sequence of ';'-delimited statements until a terminator.
171
+ """
172
+ out: list[exp.Expression] = []
173
+ parse = parse_one or self._parse_statement
174
+
175
+ # Normalize stop_texts to uppercase for lexeme comparison
176
+ stop_texts_upper = tuple(s.upper() for s in stop_texts)
177
+
178
+ while True:
179
+ # If we've consumed the current chunk, move to the next one
180
+ if self._index >= self._tokens_size:
181
+ self._advance_chunk()
182
+
183
+ # No more tokens
184
+ if not self._curr:
185
+ break
186
+
187
+ # Stop on a token-type terminator
188
+ if stop_tokens and self._match_set(stop_tokens, advance=False):
189
+ break
190
+
191
+ # Stop on a lexeme (identifier-like) terminator
192
+ if stop_texts_upper and self._curr.text.upper() in stop_texts_upper:
193
+ break
194
+
195
+ # Parse one unit from this chunk
196
+ node = parse()
197
+ if node is not None:
198
+ out.append(node)
199
+
200
+ if strict:
201
+ # After parsing a unit, there should be no leftover tokens in the chunk
202
+ if self._index < self._tokens_size:
203
+ self.raise_error(error_msg)
204
+ self.check_errors()
205
+
206
+ self._advance_chunk()
207
+
208
+ return out
209
+
210
+ def _parse_plpgsql_block(self) -> PGBlock:
211
+ declare = None
212
+
213
+ entry = self._prev.token_type if self._prev else None
214
+ if entry == TokenType.DECLARE:
215
+ declare = self._parse_pldeclare()
216
+ self._match_expect(TokenType.BEGIN)
217
+ elif entry != TokenType.BEGIN:
218
+ self.raise_error("Expected BEGIN or DECLARE to start PL/pgSQL block")
219
+
220
+ expressions = self._parse_statement_body(
221
+ stop_texts=("EXCEPTION",),
222
+ stop_tokens=(TokenType.END,),
223
+ error_msg="Invalid expression / Unexpected token",
224
+ strict=True,
225
+ parse_one=self._parse_block_unit,
226
+ )
227
+
228
+ exception = None
229
+ # If EXCEPTION section follows, parse it now (helper didn't consume it)
230
+ if self._match_texts("EXCEPTION"):
231
+ exception = self._parse_pgexception()
232
+
233
+ # Require END to close the block
234
+ self._match_expect(TokenType.END)
235
+
236
+ # Optional trailing label and statement terminator
237
+ label = self._parse_id_var(any_token=True)
238
+ self._match(TokenType.SEMICOLON)
239
+
240
+ return self.expression(
241
+ PGBlock(expressions=expressions, declare=declare, exception=exception, begin=True, label=label)
242
+ )
243
+
244
+ def _parse_pg_assignment(self) -> exp.PropertyEQ | None:
245
+ """Parse a simple PL/pgSQL assignment statement inside a block.
246
+
247
+ Pattern: <identifier> := <expression>
248
+
249
+ Notes:
250
+ - We do NOT accept '=' as assignment. If we detect '<ident> =', raise a ParseError.
251
+ - Parsing is local to the current chunk (terminated by ';').
252
+ """
253
+ index = self._index
254
+
255
+ # LHS must be an identifier-like variable
256
+ lhs = self._parse_id_var()
257
+ if not lhs:
258
+ self._retreat(index)
259
+ return None
260
+
261
+ if self._match(TokenType.EQ, advance=False):
262
+ self.raise_error("Use := for assignment in PL/pgSQL blocks")
263
+
264
+ pair = self._parse_named_pair(
265
+ lhs,
266
+ allowed_ops={TokenType.COLON_EQ},
267
+ rhs_parser=self._parse_expression,
268
+ )
269
+ if pair is None:
270
+ self._retreat(index)
271
+ return None
272
+
273
+ return pair
274
+
275
+ def _parse_pgexception(self) -> exp.Expression:
276
+ whens: list[exp.Expression] = []
277
+
278
+ # Parse one or more WHEN clauses
279
+ while self._match(TokenType.WHEN, advance=False):
280
+ whens.append(self._parse_pgwhen())
281
+
282
+ if not whens:
283
+ self.raise_error("Invalid expression / Unexpected token")
284
+
285
+ return self.expression(PGException(whens=whens))
286
+
287
+ def _parse_pgwhen(self) -> exp.Expression:
288
+ self._match_expect(TokenType.WHEN)
289
+
290
+ conditions: list[exp.Expression] = []
291
+
292
+ # First condition (identifier or SQLSTATE 'xxxxx')
293
+ cond = self._parse_pgwhen_condition()
294
+ if cond is not None:
295
+ conditions.append(cond)
296
+
297
+ # Parse one or more condition identifiers separated by OR; AND is not allowed
298
+ while self._match(TokenType.OR):
299
+ more = self._parse_pgwhen_condition()
300
+ if more is None:
301
+ self.raise_error("Expected condition after OR")
302
+ conditions.append(more)
303
+
304
+ if not conditions:
305
+ self.raise_error("WHEN requires at least one condition")
306
+
307
+ self._match_expect(TokenType.THEN)
308
+
309
+ # Parse one or more statements until next WHEN or END
310
+ thens: list[exp.Expression] = self._parse_statement_body(
311
+ stop_texts=("WHEN",),
312
+ stop_tokens=(TokenType.END,),
313
+ error_msg="Invalid expression in WHEN body / Unexpected token",
314
+ strict=True,
315
+ )
316
+
317
+ if not thens:
318
+ self.raise_error("WHEN body requires at least one statement")
319
+
320
+ # Combine multiple conditions into a single OR expression like exp.Case does
321
+ condition_expr = conditions[0]
322
+ if len(conditions) > 1:
323
+ condition_expr = exp.or_(*conditions)
324
+
325
+ return self.expression(PGWhen(condition=condition_expr, then=thens))
326
+
327
+ def _parse_pgwhen_condition(self) -> exp.Expression | None:
328
+ if self._match_texts(("OTHERS",)):
329
+ return self.expression(PGOthers())
330
+
331
+ if self._match_texts(("SQLSTATE",)):
332
+ return self._parse_sqlstate()
333
+
334
+ return self._parse_id_var()
335
+
336
+ def _parse_sqlstate(self) -> PGSqlState | None:
337
+ """Consume a quoted literal after SQLSTATE."""
338
+ if self._match(TokenType.STRING, advance=False):
339
+ lit = self._parse_primary()
340
+ if isinstance(lit, exp.Literal) and lit.is_string:
341
+ return self.expression(PGSqlState(this=lit))
342
+ self.raise_error("SQLSTATE must be followed by a quoted literal")
343
+
344
+ def _parse_pgif(self) -> PGIf:
345
+ # First branch starts immediately after IF
346
+ branches: list[exp.Expression] = [self._parse_pgif_branch()]
347
+
348
+ # Zero or more ELSIF / ELSEIF branches
349
+ while self._match_texts(("ELSIF", "ELSEIF")):
350
+ branches.append(self._parse_pgif_branch())
351
+
352
+ # Optional ELSE branch
353
+ default: list[exp.Expression] | None = None
354
+ if self._match(TokenType.ELSE):
355
+ default = self._parse_pgif_body()
356
+
357
+ self._match_expect(TokenType.END)
358
+ self._match_expect(TokenType.IF)
359
+
360
+ return self.expression(PGIf(ifs=branches, default=default))
361
+
362
+ def _parse_pgcase(self) -> PGCase:
363
+ search_expr: exp.Expression | None = None
364
+ if not self._match(TokenType.WHEN, advance=False):
365
+ search_expr = self._parse_expression()
366
+
367
+ branches: list[exp.Expression] = []
368
+ while self._match(TokenType.WHEN, advance=False):
369
+ branches.append(self._parse_pgcasewhen(search_expr))
370
+
371
+ if not branches:
372
+ self.raise_error("CASE requires at least one WHEN branch")
373
+
374
+ default: list[exp.Expression] | None = None
375
+ if self._match(TokenType.ELSE):
376
+ default = self._parse_statement_body(
377
+ stop_tokens=(TokenType.END,),
378
+ error_msg="Invalid expression in CASE ELSE body / Unexpected token",
379
+ strict=True,
380
+ parse_one=self._parse_block_unit,
381
+ )
382
+
383
+ self._match_expect(TokenType.END)
384
+ self._match_expect(TokenType.CASE)
385
+
386
+ return self.expression(PGCase(this=search_expr, ifs=branches, default=default))
387
+
388
+ def _parse_pgcasewhen(self, search_expr: exp.Expression | None) -> PGWhen:
389
+ self._match_expect(TokenType.WHEN)
390
+
391
+ if search_expr is None:
392
+ condition = self._parse_expression()
393
+ self._match_expect(TokenType.THEN)
394
+ else:
395
+ if self._match(TokenType.THEN, advance=False):
396
+ self.raise_error("CASE search WHEN requires at least one expression before THEN")
397
+
398
+ comparisons = [exp.EQ(this=search_expr.copy(), expression=self._parse_expression())]
399
+ while self._match(TokenType.COMMA):
400
+ comparisons.append(exp.EQ(this=search_expr.copy(), expression=self._parse_expression()))
401
+
402
+ self._match_expect(TokenType.THEN)
403
+ condition = comparisons[0] if len(comparisons) == 1 else exp.or_(*comparisons)
404
+
405
+ then_body = self._parse_statement_body(
406
+ stop_tokens=(TokenType.ELSE, TokenType.END),
407
+ stop_texts=("WHEN",),
408
+ error_msg="Invalid expression in CASE WHEN body / Unexpected token",
409
+ strict=True,
410
+ parse_one=self._parse_block_unit,
411
+ )
412
+
413
+ if not then_body:
414
+ self.raise_error("CASE WHEN body requires at least one statement")
415
+
416
+ return self.expression(PGWhen(condition=condition, then=then_body))
417
+
418
+ def _parse_pgif_branch(self) -> PGIfBranch:
419
+ # Parse <condition> THEN <statements>
420
+ condition = self._parse_expression()
421
+ self._match_expect(TokenType.THEN)
422
+
423
+ then_body = self._parse_pgif_body()
424
+ if not then_body:
425
+ self.raise_error("IF branch requires at least one statement")
426
+
427
+ return self.expression(PGIfBranch(condition=condition, then=then_body))
428
+
429
+ def _parse_pgif_body(self) -> list[exp.Expression]:
430
+ return self._parse_statement_body(
431
+ stop_texts=("ELSIF", "ELSEIF"),
432
+ stop_tokens=(TokenType.ELSE, TokenType.END),
433
+ error_msg="Invalid expression in IF body / Unexpected token",
434
+ strict=True,
435
+ parse_one=self._parse_block_unit,
436
+ )
437
+
438
+ def _parse_block_unit(self) -> exp.Expression | None:
439
+ assignment = self._parse_pg_assignment()
440
+ if assignment is not None:
441
+ return assignment
442
+ return self._parse_statement()
443
+
444
+ def _parse_pldeclare(self) -> exp.Declare:
445
+ items: list[exp.Expression] = self._parse_statement_body(
446
+ stop_texts=("BEGIN",),
447
+ stop_tokens=(),
448
+ error_msg="Invalid DECLARE item / Unexpected token",
449
+ strict=True,
450
+ parse_one=self._parse_pldeclareitem,
451
+ )
452
+
453
+ return self.expression(exp.Declare(expressions=items))
454
+
455
+ def _parse_pldeclareitem(self) -> PGDeclareItem | None:
456
+ ident = self._parse_id_var()
457
+ if not ident:
458
+ return None
459
+
460
+ # Optional CONSTANT modifier
461
+ is_constant = self._match_texts(("CONSTANT",))
462
+
463
+ # Optional ALIAS FOR $n variant: must come before type parsing
464
+ if self._match_text_seq("ALIAS", "FOR"):
465
+ # Parse a positional parameter like $1
466
+ param_expr = self._parse_identifier()
467
+ return self.expression(
468
+ PGDeclareItem(
469
+ this=ident,
470
+ alias_for=True,
471
+ expression=param_expr,
472
+ value=param_expr,
473
+ constant=is_constant,
474
+ )
475
+ )
476
+
477
+ # Detect cursor declaration before type parsing:
478
+ index = self._index
479
+ scroll: bool | None = None
480
+ if self._match_texts(("NO",)):
481
+ if self._match_texts(("SCROLL",)):
482
+ scroll = False
483
+ else:
484
+ # Not a valid cursor prelude, rollback
485
+ self._retreat(index)
486
+ elif self._match_texts(("SCROLL",)):
487
+ scroll = True
488
+
489
+ # If SCROLL/NO SCROLL matched, require CURSOR next; else try bare CURSOR
490
+ if scroll is not None:
491
+ if self._match_texts(("CURSOR",)):
492
+ return self._parse_pl_declare_cursor(ident, is_constant, scroll)
493
+ self._retreat(index)
494
+ elif self._match_texts(("CURSOR",)):
495
+ return self._parse_pl_declare_cursor(ident, is_constant, None)
496
+
497
+ type_expr = self._parse_pl_type_or_typeref()
498
+
499
+ # Optional COLLATE clause after type
500
+ collate_expr = None
501
+ if self._match(TokenType.COLLATE):
502
+ # Collation can be an identifier or quoted identifier
503
+ collate_expr = self._parse_id_var()
504
+
505
+ # Optional NOT NULL constraint (sits after COLLATE and before DEFAULT)
506
+ not_null = False
507
+ if self._match_text_seq("NOT", "NULL"):
508
+ not_null = True
509
+
510
+ # Optional initialization with DEFAULT or assignment (:= or =)
511
+ init_expr = None
512
+ is_default = False
513
+ assign_op: str | None = None
514
+ if self._match(TokenType.DEFAULT):
515
+ is_default = True
516
+ init_expr = self._parse_bitwise()
517
+ elif self._match(TokenType.COLON_EQ):
518
+ assign_op = ":="
519
+ init_expr = self._parse_bitwise()
520
+ elif self._match(TokenType.EQ):
521
+ assign_op = "="
522
+ init_expr = self._parse_bitwise()
523
+
524
+ return self.expression(
525
+ PGDeclareItem(
526
+ this=ident,
527
+ kind=type_expr,
528
+ collate=collate_expr,
529
+ not_null=not_null,
530
+ expression=init_expr,
531
+ value=init_expr,
532
+ default=is_default,
533
+ assign=assign_op,
534
+ constant=is_constant,
535
+ )
536
+ )
537
+
538
+ def _parse_pl_type_or_typeref(self) -> exp.Expression | None:
539
+ index = self._index
540
+
541
+ this = self._parse_id_var()
542
+ ref = None
543
+
544
+ if this is not None:
545
+ table = None
546
+ if self._match(TokenType.DOT):
547
+ column = self._parse_id_var()
548
+ if column is None:
549
+ self._retreat(index)
550
+ return self._parse_type() or self._parse_types()
551
+ table = this
552
+ this = column
553
+
554
+ ref = self.expression(exp.Column(this=this, table=table))
555
+
556
+ if ref is not None and self._match(TokenType.MOD):
557
+ rowtype = False
558
+ if self._match(TokenType.TYPE):
559
+ rowtype = False
560
+ elif self._match_text_seq("ROWTYPE"):
561
+ rowtype = True
562
+ else:
563
+ self._retreat(index)
564
+ return self._parse_type() or self._parse_types()
565
+
566
+ array = False
567
+ if self._match(TokenType.L_BRACKET):
568
+ self._match_expect(TokenType.R_BRACKET)
569
+ array = True
570
+ return self.expression(PGTypeRef(this=ref, array=array, rowtype=rowtype))
571
+
572
+ self._retreat(index)
573
+ return self._parse_types() or self._parse_type()
574
+
575
+ def _parse_pl_declare_cursor(
576
+ self, ident: exp.Expression, is_constant: bool, scroll: bool | None
577
+ ) -> PGDeclareItem:
578
+ # Optional argument declaration list: ( name type [, ...] )
579
+ cursor_args: list[exp.Expression] | None = None
580
+ if self._match(TokenType.L_PAREN):
581
+ cursor_args = self._parse_csv(self._parse_pl_cursor_arg)
582
+ self._match_expect(TokenType.R_PAREN)
583
+
584
+ # Required FOR <query>
585
+ self._match_expect(TokenType.FOR)
586
+
587
+ query = self._parse_statement()
588
+ if query is None:
589
+ self.raise_error("Expected query after CURSOR ... FOR")
590
+
591
+ return self.expression(
592
+ PGDeclareItem(
593
+ this=ident,
594
+ cursor=True,
595
+ scroll=scroll,
596
+ cursor_args=cursor_args,
597
+ query=query,
598
+ constant=is_constant,
599
+ )
600
+ )
601
+
602
+ def _parse_pl_cursor_arg(self) -> PGCursorArg:
603
+ name = self._parse_id_var()
604
+ arg_type = self._parse_type() or self._parse_types()
605
+ return self.expression(PGCursorArg(this=name, kind=arg_type))
606
+
607
+
608
+ def _parse_returning(self) -> exp.Returning | None:
609
+ # Override base to support INTO [STRICT] <target>[, ...]
610
+ if not self._match(TokenType.RETURNING):
611
+ return None
612
+
613
+ expressions = self._parse_csv(self._parse_expression)
614
+
615
+ into = None
616
+ if self._match(TokenType.INTO):
617
+ strict = self._match_texts(("STRICT",))
618
+ # Parse one or more identifier-like targets (variables)
619
+ targets = self._parse_csv(lambda: self._parse_id_var(any_token=True))
620
+ if not targets:
621
+ self.raise_error("Expected target list after INTO")
622
+
623
+ into = self.expression(
624
+ PGInto(
625
+ this=targets[0],
626
+ expressions=targets if len(targets) > 1 else None,
627
+ strict=strict,
628
+ )
629
+ )
630
+
631
+ return self.expression(PGReturning(expressions=expressions, into=into))
632
+
633
+ def _parse_into(self) -> exp.Into | None:
634
+ if not self._match(TokenType.INTO):
635
+ return None
636
+
637
+ strict = self._match_texts(("STRICT",))
638
+ temp = self._match(TokenType.TEMPORARY)
639
+ unlogged = self._match_text_seq("UNLOGGED")
640
+ self._match(TokenType.TABLE)
641
+
642
+ targets = self._parse_csv(lambda: self._parse_table(schema=True))
643
+ if not targets:
644
+ self.raise_error("Expected target list after INTO")
645
+
646
+ return self.expression(
647
+ PGInto(
648
+ this=targets[0],
649
+ expressions=targets if len(targets) > 1 else None,
650
+ temporary=temp,
651
+ unlogged=unlogged,
652
+ strict=strict,
653
+ )
654
+ )
655
+
656
+ def _extract_trailing_where_current_of(self) -> tuple[int, exp.Identifier] | None:
657
+ statement_index = self._index
658
+ where_index = -1
659
+
660
+ for index in range(statement_index, self._tokens_size - 3):
661
+ token = self._tokens[index]
662
+ if token.text.upper() != "WHERE":
663
+ continue
664
+
665
+ if (
666
+ self._tokens[index + 1].text.upper() == "CURRENT"
667
+ and self._tokens[index + 2].text.upper() == "OF"
668
+ ):
669
+ where_index = index
670
+
671
+ if where_index < 0:
672
+ return None
673
+
674
+ cursor_token = self._tokens[where_index + 3]
675
+ if cursor_token.token_type not in {TokenType.IDENTIFIER, TokenType.VAR, TokenType.STRING}:
676
+ self.raise_error("Expected cursor name after WHERE CURRENT OF", token=cursor_token)
677
+
678
+ cursor = exp.to_identifier(
679
+ cursor_token.text,
680
+ quoted=cursor_token.token_type in {TokenType.IDENTIFIER, TokenType.STRING},
681
+ )
682
+ cursor.update_positions(cursor_token)
683
+
684
+ for trailing_token in self._tokens[where_index + 4 : self._tokens_size]:
685
+ if trailing_token.token_type not in {TokenType.SEMICOLON, TokenType.SENTINEL}:
686
+ self.raise_error("Invalid expression after WHERE CURRENT OF", token=trailing_token)
687
+
688
+ return where_index, cursor
689
+
690
+ def _parse_pg_dml_with_current_of(self, parse: t.Callable[[], exp.Expression]) -> tuple[exp.Expression, exp.Identifier | None]:
691
+ current_of_info = self._extract_trailing_where_current_of()
692
+ original_tokens_size = self._tokens_size
693
+
694
+ if current_of_info:
695
+ self._tokens_size = current_of_info[0]
696
+
697
+ statement = parse()
698
+ self._tokens_size = original_tokens_size
699
+
700
+ if current_of_info:
701
+ self._retreat(original_tokens_size)
702
+
703
+ return statement, current_of_info and current_of_info[1]
704
+
705
+ def _parse_pgupdate(self) -> PGUpdate:
706
+ update, current_of = self._parse_pg_dml_with_current_of(super()._parse_update)
707
+ return self.expression(PGUpdate(**update.args, current_of=current_of))
708
+
709
+ def _parse_pgdelete(self) -> PGDelete:
710
+ delete, current_of = self._parse_pg_dml_with_current_of(super()._parse_delete)
711
+ return self.expression(PGDelete(**delete.args, current_of=current_of))
712
+
713
+ def _parse_pgwhile(self) -> exp.WhileBlock:
714
+ # Parse condition expression
715
+ cond = self._parse_expression()
716
+
717
+ # Guard against accidental aliasing like: <cond> AS LOOP
718
+ if isinstance(cond, exp.Alias):
719
+ alias = cond.args.get("alias")
720
+ if isinstance(alias, exp.Identifier) and alias.name.upper() == "LOOP":
721
+ cond = cond.this
722
+ else:
723
+ self._match_expect(TokenType.LOOP)
724
+ else:
725
+ self._match_expect(TokenType.LOOP)
726
+
727
+ # Parse loop body and optional trailing label using shared helpers
728
+ body = self._parse_loop_body()
729
+ label = self._parse_loop_label()
730
+
731
+ return self.expression(exp.WhileBlock(this=cond, body=body, label=label))
732
+
733
+ def _parse_pgperform(self) -> PGPerform:
734
+ if self._match(TokenType.SENTINEL, advance=False):
735
+ self.raise_error("Expected query body after PERFORM")
736
+
737
+ perform_index = self._index - 1
738
+ perform_token = self._tokens[perform_index]
739
+ original_type = perform_token.token_type
740
+
741
+ perform_token.token_type = TokenType.SELECT
742
+ try:
743
+ self._retreat(perform_index)
744
+ query = self._parse_select()
745
+ finally:
746
+ perform_token.token_type = original_type
747
+
748
+ if not isinstance(query, exp.Select):
749
+ self.raise_error("Expected query body after PERFORM")
750
+
751
+ return self.expression(PGPerform(**query.args))
752
+
753
+ def _parse_pgfor(self) -> PGForIn:
754
+ # Parse loop target identifier/variable
755
+ target = self._parse_id_var()
756
+
757
+ self._match_expect(TokenType.IN)
758
+ reverse = self._match(TokenType.REVERSE)
759
+
760
+ # Try range form: <start> .. <end> [BY <step>] LOOP
761
+ start_index = self._index
762
+ start_expr = self._parse_bitwise()
763
+ if start_expr is not None and self._match(TokenType.DDOT):
764
+ end_expr = self._parse_bitwise()
765
+ step_expr = self._parse_bitwise() if self._match(TokenType.BY) else None
766
+ self._match_expect(TokenType.LOOP)
767
+ body = self._parse_loop_body()
768
+ return self.expression(
769
+ PGForIn(
770
+ this=target,
771
+ reverse=reverse or None,
772
+ start=start_expr,
773
+ end=end_expr,
774
+ step=step_expr,
775
+ expressions=body,
776
+ label=self._parse_loop_label(),
777
+ )
778
+ )
779
+
780
+ if reverse:
781
+ self.raise_error("Expected '..' range after REVERSE in FOR header")
782
+
783
+ # Not a range: rewind and parse the query/cursor form
784
+ self._retreat(start_index)
785
+ return self._parse_pgfor_query(target)
786
+
787
+ def _parse_pgfor_query(self, target: exp.Expression) -> PGForIn:
788
+ # If the header begins with EXECUTE, parse via _parse_pgexecute and
789
+ # reject INTO / INTO STRICT in this context, mirroring
790
+ # RETURN QUERY EXECUTE semantics.
791
+ if self._match(TokenType.EXECUTE):
792
+ query = self._parse_pgexecute()
793
+ if query.args.get("expressions") or query.args.get("strict"):
794
+ self.raise_error("INTO is not allowed in FOR IN EXECUTE")
795
+ else:
796
+ start_index = self._index
797
+ query = None
798
+
799
+ cursor = self._parse_id_var()
800
+ if cursor is not None:
801
+ args = None
802
+ if self._match(TokenType.L_PAREN, advance=False):
803
+ args = self._parse_cursor_call_args()
804
+
805
+ # Cursor call only if the header ends here (next token is LOOP)
806
+ if self._match(TokenType.LOOP, advance=False):
807
+ query = self.expression(PGCursorCall(this=cursor, expressions=args))
808
+
809
+ if query is None:
810
+ self._retreat(start_index)
811
+ query = self._parse_statement()
812
+
813
+ self._match_expect(TokenType.LOOP)
814
+ body = self._parse_loop_body()
815
+ return self.expression(
816
+ PGForIn(
817
+ this=target,
818
+ query=query,
819
+ expressions=body,
820
+ label=self._parse_loop_label(),
821
+ )
822
+ )
823
+
824
+ def _parse_pgforeach(self) -> PGForEach:
825
+ # Loop target variable
826
+ target = self._parse_id_var()
827
+
828
+ slice_expr = None
829
+ if self._match_texts(("SLICE",)):
830
+ slice_expr = self._parse_number()
831
+ if slice_expr is None:
832
+ self.raise_error("Expected a number after SLICE")
833
+
834
+ self._match_expect(TokenType.IN)
835
+ self._match_expect(TokenType.ARRAY)
836
+
837
+ # The array expression stops at the LOOP token naturally.
838
+ array_expr = self._parse_bitwise()
839
+ self._match_expect(TokenType.LOOP)
840
+ body = self._parse_loop_body()
841
+ return self.expression(
842
+ PGForEach(
843
+ this=target,
844
+ slice=slice_expr,
845
+ expression=array_expr,
846
+ expressions=body,
847
+ label=self._parse_loop_label(),
848
+ )
849
+ )
850
+
851
+ def _parse_loop_body(self) -> list[exp.Expression]:
852
+ body = self._parse_statement_body(stop_tokens=(TokenType.END,), strict=False)
853
+ self._match_expect(TokenType.END)
854
+ self._match_expect(TokenType.LOOP)
855
+ return body or []
856
+
857
+ def _parse_loop_label(self) -> exp.Expression | None:
858
+ label = self._parse_id_var(any_token=True)
859
+ self._match(TokenType.SEMICOLON)
860
+ return label
861
+
862
+ def _parse_pgassert(self) -> PGAssert:
863
+ condition = self._parse_expression()
864
+
865
+ message = None
866
+ if self._match(TokenType.COMMA):
867
+ message = self._parse_expression()
868
+
869
+ return self.expression(PGAssert(condition=condition, message=message))
870
+
871
+ def _parse_pgraise(self) -> PGRaise:
872
+ level = None
873
+ message: exp.Expression | None = None
874
+ fmt_args: list[exp.Expression] | None = None
875
+ condition = None
876
+ sqlstate: exp.Expression | None = None
877
+ using_args: list[exp.Expression] | None = None
878
+
879
+ # Optional level
880
+ if self._curr is not None and self._curr.text.upper() in self._RAISE_LEVELS:
881
+ # Treat as identifier to keep minimal AST
882
+ token = self._curr
883
+ self._advance()
884
+ ident = exp.to_identifier(token.text)
885
+ ident.update_positions(token)
886
+ level = ident
887
+
888
+ # Helper to parse USING options into PropertyEQ/EQ entries
889
+ def _parse_using_list() -> list[exp.Expression]:
890
+ # Current token should be USING (not yet consumed)
891
+ self._match_expect(TokenType.USING)
892
+ opts: list[exp.Expression] = []
893
+ # Parse CSV of name {:=|=} expr
894
+ first = True
895
+ while True:
896
+ # Name is a simple identifier or variable token
897
+ name = self._parse_id_var(any_token=True)
898
+ if not name:
899
+ if first:
900
+ self.raise_error("Expected option name after USING")
901
+ break
902
+
903
+ pair = self._parse_named_pair(
904
+ name,
905
+ allowed_ops={TokenType.COLON_EQ, TokenType.EQ},
906
+ rhs_parser=self._parse_expression,
907
+ )
908
+ if pair is None:
909
+ self.raise_error("Expected ':=' or '=' in USING option")
910
+ opts.append(pair)
911
+
912
+ first = False
913
+ if not self._match(TokenType.COMMA):
914
+ break
915
+
916
+ return opts
917
+
918
+ # Branch on next token for main payload
919
+ if self._curr is None or self._index >= self._tokens_size:
920
+ # Bare re-raise
921
+ pass
922
+ elif self._match_texts("SQLSTATE"):
923
+ sqlstate = self._parse_sqlstate()
924
+ elif self._match(TokenType.STRING, advance=False):
925
+ # Message/format expression (typically a string)
926
+ message = self._parse_expression()
927
+ # Optional CSV of expressions
928
+ args: list[exp.Expression] = []
929
+ while self._match(TokenType.COMMA):
930
+ args.append(self._parse_expression())
931
+ if args:
932
+ fmt_args = args
933
+ elif self._match(TokenType.USING, advance=False):
934
+ # USING-only form
935
+ using_args = _parse_using_list()
936
+ else:
937
+ # Condition name (identifier)
938
+ condition = self._parse_id_var(any_token=True)
939
+
940
+ # Optional USING after message/condition/sqlstate
941
+ if using_args is None and self._match(TokenType.USING, advance=False):
942
+ using_args = _parse_using_list()
943
+
944
+ return self.expression(
945
+ PGRaise(
946
+ level=level,
947
+ message=message,
948
+ expressions=fmt_args,
949
+ condition=condition,
950
+ sqlstate=sqlstate,
951
+ using=using_args,
952
+ )
953
+ )
954
+
955
+ def _parse_pgreturn(self) -> PGReturn:
956
+ # Support RETURN QUERY <query>; and RETURN QUERY EXECUTE ... [USING ...];
957
+ if self._match_texts("QUERY"):
958
+ # Fast-path for dynamic EXECUTE variant
959
+ if self._match(TokenType.EXECUTE):
960
+ exec_node = self._parse_pgexecute()
961
+
962
+ # Prohibit INTO / INTO STRICT in RETURN QUERY EXECUTE form
963
+ if exec_node.args.get("expressions") or exec_node.args.get("strict"):
964
+ self.raise_error("INTO is not allowed in RETURN QUERY EXECUTE")
965
+
966
+ return self.expression(PGReturn(this=exec_node, query=True))
967
+
968
+ # Otherwise parse a full query-producing statement (SELECT/WITH/VALUES/INSERT ... RETURNING)
969
+ query = self._parse_statement()
970
+ if query is None:
971
+ self.raise_error("Expected query after RETURN QUERY")
972
+ return self.expression(PGReturn(this=query, query=True))
973
+
974
+ # Support RETURN NEXT <expression>;
975
+ if self._match_texts("NEXT"):
976
+ # NEXT must be followed by an expression on the same chunk
977
+ if self._curr is None or self._index >= self._tokens_size:
978
+ self.raise_error("Expected expression after RETURN NEXT")
979
+ expr = self._parse_expression()
980
+ return self.expression(PGReturn(this=expr, next=True))
981
+
982
+ # Otherwise, optional expression on the same chunk before semicolon.
983
+ # If end of chunk reached immediately, it's a bare RETURN.
984
+ expr: exp.Expression | None = None
985
+ if self._curr is not None and self._index < self._tokens_size:
986
+ expr = self._parse_expression()
987
+
988
+ return self.expression(PGReturn(this=expr))
989
+
990
+ def _parse_pgloop(self) -> PGLoop:
991
+ # Parse body statements until END using the shared helper
992
+ body: list[exp.Expression] = self._parse_statement_body(
993
+ stop_tokens=(TokenType.END,),
994
+ error_msg="Invalid expression inside LOOP / Unexpected token",
995
+ strict=True,
996
+ )
997
+
998
+ # Consume END and then require trailing LOOP
999
+ self._match_expect(TokenType.END)
1000
+ self._match_expect(TokenType.LOOP)
1001
+ # Optional trailing label and statement terminator
1002
+ label = self._parse_loop_label()
1003
+
1004
+ return self.expression(PGLoop(body=body, end_label=label))
1005
+
1006
+ def _parse_pgexit_or_continue(self, *, token_type: TokenType, expr_cls: type[exp.Expression]):
1007
+ """Parse EXIT/CONTINUE constructs which share the same grammar.
1008
+
1009
+ Syntax: <KEYWORD> [label] [WHEN <expr>]
1010
+ """
1011
+ label = None
1012
+ condition = None
1013
+
1014
+ # Optional label unless the next token starts a WHEN clause
1015
+ if self._curr is not None and self._curr.text.upper() != "WHEN":
1016
+ label = self._parse_id_var()
1017
+
1018
+ # Optional WHEN <expression>
1019
+ if self._match(TokenType.WHEN):
1020
+ condition = self._parse_expression()
1021
+
1022
+ # Build node. For EXIT, use the PL/pgSQL subclass (PGExit) which extends
1023
+ # the shared exp.Leave with an optional WHEN clause.
1024
+ return self.expression(expr_cls(this=label, when=condition))
1025
+
1026
+ def _parse_pgexit(self) -> PGExit:
1027
+ return self._parse_pgexit_or_continue(token_type=TokenType.EXIT, expr_cls=PGExit)
1028
+
1029
+ def _parse_pgcontinue(self) -> PGContinue:
1030
+ return self._parse_pgexit_or_continue(token_type=TokenType.CONTINUE, expr_cls=PGContinue)
1031
+
1032
+ def _parse_cursor_call_args(self) -> list[exp.Expression]:
1033
+ """Parse a parenthesized cursor argument list and consume the closing ')'."""
1034
+ self._advance()
1035
+
1036
+ args: list[exp.Expression] = []
1037
+ if not self._match(TokenType.R_PAREN, advance=False):
1038
+ while True:
1039
+ first = self._parse_expression()
1040
+ pair = self._parse_named_pair(
1041
+ first,
1042
+ allowed_ops={TokenType.COLON_EQ, TokenType.FARROW},
1043
+ rhs_parser=self._parse_expression,
1044
+ )
1045
+ args.append(pair or first)
1046
+
1047
+ if not self._match(TokenType.COMMA):
1048
+ break
1049
+ if self._match(TokenType.R_PAREN, advance=False):
1050
+ self.raise_error("Trailing comma is not allowed in cursor argument list")
1051
+
1052
+ self._match_expect(TokenType.R_PAREN)
1053
+
1054
+ return args
1055
+
1056
+ def _parse_pgopen(self) -> PGOpenCursor:
1057
+ # Cursor variable identifier
1058
+ cursor = self._parse_id_var()
1059
+
1060
+ # Bound cursor with arguments: OPEN c(<args>) where args can be positional,
1061
+ # name := value (PropertyEQ), or name => value (Kwarg)
1062
+ if self._match(TokenType.L_PAREN, advance=False):
1063
+ # Parse cursor call arguments directly (positional, name := value, name => value)
1064
+ args = self._parse_cursor_call_args()
1065
+ return self.expression(PGOpenCursor(this=cursor, expressions=args))
1066
+
1067
+ # Optional [[NO] SCROLL]
1068
+ scroll: bool | None = None
1069
+ if self._match_texts("NO"):
1070
+ self._match_text_expect("SCROLL")
1071
+ scroll = False
1072
+ elif self._match_texts("SCROLL"):
1073
+ scroll = True
1074
+
1075
+ # If there's no FOR and no SCROLL/NO SCROLL, treat as bound cursor with zero args: OPEN c;
1076
+ if not self._match(TokenType.FOR):
1077
+ if scroll is None:
1078
+ return self.expression(PGOpenCursor(this=cursor))
1079
+ # If SCROLL/NO SCROLL was provided, FOR is required
1080
+ self._match_expect(TokenType.FOR)
1081
+
1082
+ if self._match(TokenType.EXECUTE):
1083
+ query = self._parse_pgexecute()
1084
+ if query.args["expressions"]:
1085
+ self.raise_error("INTO is not allowed in OPEN FOR EXECUTE")
1086
+ else:
1087
+ query = self._parse_statement()
1088
+ if query is None:
1089
+ self.raise_error("Expected query after OPEN ... FOR")
1090
+
1091
+ return self.expression(PGOpenCursor(this=cursor, expression=query, scroll=scroll))
1092
+
1093
+ def _parse_pgfetch(self) -> PGFetch:
1094
+ # Parse optional direction, optional preposition (required when direction given), and cursor
1095
+ direction, preposition, cursor = self._parse_pg_direction_and_cursor(after_kw="FETCH")
1096
+
1097
+ # INTO keyword
1098
+ self._match_expect(TokenType.INTO)
1099
+
1100
+ # One or more targets separated by commas on the same chunk
1101
+ targets = self._parse_csv(self._parse_expression)
1102
+
1103
+ if not targets:
1104
+ self.raise_error("Expected target list after INTO in FETCH")
1105
+
1106
+ return self.expression(
1107
+ PGFetch(
1108
+ this=cursor,
1109
+ expressions=targets,
1110
+ direction=direction,
1111
+ preposition=preposition,
1112
+ )
1113
+ )
1114
+
1115
+ def _parse_pgmove(self) -> "PGMove":
1116
+ # Parse optional direction, optional preposition (required when direction given), and cursor
1117
+ direction, preposition, cursor = self._parse_pg_direction_and_cursor(after_kw="MOVE")
1118
+
1119
+ return self.expression(
1120
+ PGMove(
1121
+ this=cursor,
1122
+ direction=direction,
1123
+ preposition=preposition,
1124
+ )
1125
+ )
1126
+
1127
+ def _parse_pgclose(self) -> "PGClose":
1128
+ cursor = self._parse_id_var()
1129
+ return self.expression(PGClose(this=cursor))
1130
+
1131
+ def _parse_pgexecute(self) -> PGExecute:
1132
+ # Command-string: any scalar expression (literal, '||' concat, function call).
1133
+ # _parse_expression naturally stops at INTO / USING (non-alias tokens).
1134
+ command = self._parse_expression()
1135
+ if command is None:
1136
+ self.raise_error("Expected command string after EXECUTE")
1137
+
1138
+ targets: list[exp.Expression] | None = None
1139
+ strict = False
1140
+ using: list[exp.Expression] | None = None
1141
+
1142
+ # Optional INTO [STRICT] target [, ...]
1143
+ if self._match(TokenType.INTO):
1144
+ # STRICT is tokenized as VAR, so match by text
1145
+ strict = self._match_texts(("STRICT",))
1146
+
1147
+ # One or more targets separated by commas
1148
+ targets = self._parse_csv(self._parse_expression)
1149
+ if not targets:
1150
+ self.raise_error("Expected target list after INTO in EXECUTE")
1151
+
1152
+ # Optional USING expression [, ...]
1153
+ if self._match(TokenType.USING):
1154
+ # Require at least one expression and disallow trailing commas
1155
+ first = self._parse_expression()
1156
+ if first is None:
1157
+ self.raise_error("Expected expression list after USING in EXECUTE")
1158
+ using = [first]
1159
+ while self._match(TokenType.COMMA):
1160
+ nxt = self._parse_expression()
1161
+ if nxt is None:
1162
+ self.raise_error("Expected expression after comma in USING list")
1163
+ using.append(nxt)
1164
+
1165
+ return self.expression(
1166
+ PGExecute(
1167
+ this=command,
1168
+ expressions=targets,
1169
+ strict=strict,
1170
+ using=using or [],
1171
+ )
1172
+ )
1173
+
1174
+ def _parse_pg_direction_and_cursor(
1175
+ self, *, after_kw: str
1176
+ ) -> tuple[exp.Expression | None, str | None, exp.Expression]:
1177
+ """
1178
+ Parse optional direction and required preposition (if direction present), then cursor name.
1179
+ """
1180
+ direction: exp.Expression | None = None
1181
+ preposition: str | None = None
1182
+
1183
+ def _parse_required_preposition(msg: str) -> str:
1184
+ if self._match_texts(("FROM", "IN")):
1185
+ return (self._prev.text or "").upper()
1186
+ self.raise_error(msg)
1187
+ return ""
1188
+
1189
+ # Handle FORWARD/BACKWARD with optional count/ALL
1190
+ if self._match_texts(("FORWARD", "BACKWARD")):
1191
+ which = (self._prev.text or "").upper()
1192
+ cls = PGForward if which == "FORWARD" else PGBackward
1193
+ # Optional ALL or count after FORWARD/BACKWARD
1194
+ if self._match_texts("ALL"):
1195
+ direction = self.expression(cls(all=True))
1196
+ else:
1197
+ # A number
1198
+ if self._match_set((TokenType.NUMBER, TokenType.DASH), advance=False):
1199
+ count_expr = self._parse_bitwise()
1200
+ direction = self.expression(cls(this=count_expr))
1201
+ else:
1202
+ direction = self.expression(cls())
1203
+
1204
+ preposition = _parse_required_preposition(
1205
+ f"Expected FROM or IN after {after_kw} direction"
1206
+ )
1207
+
1208
+ elif self._match_texts(tuple(self._SIMPLE_FETCH_DIRS.keys())):
1209
+ kw = (self._prev.text or "").upper()
1210
+ direction = self.expression(self._SIMPLE_FETCH_DIRS[kw]())
1211
+ preposition = _parse_required_preposition(
1212
+ f"Expected FROM or IN after {after_kw} direction"
1213
+ )
1214
+ elif self._match_texts(("ABSOLUTE", "RELATIVE")):
1215
+ kind = (self._prev.text or "").upper()
1216
+ count_expr = self._parse_bitwise()
1217
+ direction = (
1218
+ self.expression(PGAbsolute(this=count_expr))
1219
+ if kind == "ABSOLUTE"
1220
+ else self.expression(PGRelative(this=count_expr))
1221
+ )
1222
+ preposition = _parse_required_preposition(
1223
+ f"Expected FROM or IN after {after_kw} ABSOLUTE/RELATIVE count"
1224
+ )
1225
+
1226
+ else:
1227
+ # Standalone ALL or standalone count (equivalent to FORWARD ALL / FORWARD count)
1228
+ if self._match_texts("ALL"):
1229
+ direction = self.expression(PGAll())
1230
+ preposition = _parse_required_preposition(
1231
+ f"Expected FROM or IN after {after_kw} ALL"
1232
+ )
1233
+ elif self._match_set((TokenType.NUMBER, TokenType.DASH), advance=False):
1234
+ count_expr = self._parse_bitwise()
1235
+ direction = count_expr
1236
+ preposition = _parse_required_preposition(
1237
+ f"Expected FROM or IN after {after_kw} count"
1238
+ )
1239
+ else:
1240
+ if self._match_texts(("FROM", "IN")):
1241
+ preposition = (self._prev.text or "").upper()
1242
+
1243
+ cursor = self._parse_id_var()
1244
+ return direction, preposition, cursor