packages/sql-faker/src/PostgreSql/Generation/Rewrite/WindowFrameRule.php
1<?php
2
3declare(strict_types=1);
4
5namespace SqlFaker\PostgreSql\Generation\Rewrite;
6
7use Override;
8use SqlFaker\Generation\Token\RewriteRule;
9use SqlFaker\Generation\Token\TerminalSequence;
10
11/**
12 * Implements the frame_extent rejection conditions in PostgreSQL 17.2 gram.y.
13 * @see https://github.com/postgres/postgres/blob/REL_17_2/src/backend/parser/gram.y
14 */
15final class WindowFrameRule implements RewriteRule
16{
17 /**
18 * Completes invalid frame endpoints without changing valid offsets or frames in nested expressions.
19 */
20 #[Override]
21 public function rewrite(TerminalSequence $sequence): TerminalSequence
22 {
23 foreach ($sequence->occurrences('frame_extent') as $extent) {
24 $bounds = array_values(array_filter($sequence->productions, static fn ($p): bool => $p->parent === $extent && $p->rule === 'frame_bound'));
25 if ($bounds === []) {
26 continue;
27 }
28 $start = $bounds[0]->id;
29 if ($this->rank($sequence, $start) === 4) {
30 $sequence = $this->unbounded($sequence, $start, 'PRECEDING');
31 }
32 $end = $bounds[1]->id ?? null;
33 $endRank = $end === null ? 2 : $this->rank($sequence, $end);
34 if ($end !== null && ($endRank === 0 || $this->rank($sequence, $start) > $endRank)) {
35 $sequence = $this->unbounded($sequence, $end, 'FOLLOWING');
36 } elseif ($end === null && $this->rank($sequence, $start) > 2) {
37 $range = $sequence->range($start);
38 if ($range !== null && !($sequence->nameAt($range[0] - 1) === 'BETWEEN'
39 && ($sequence->terminals[$range[0] - 1]->rewrite ?? null) === 'postgresql.frame-order')) {
40 $sequence = $sequence->replace($range[1], 0, [
41 $sequence->insertedFor('AND', $extent, 'postgresql.frame-order'),
42 $sequence->insertedFor('UNBOUNDED', $extent, 'postgresql.frame-order', 1),
43 $sequence->insertedFor('FOLLOWING', $extent, 'postgresql.frame-order', 2),
44 ], 'postgresql.frame-order');
45 $sequence = $sequence->replace($range[0], 0, [
46 $sequence->insertedFor('BETWEEN', $extent, 'postgresql.frame-order'),
47 ], 'postgresql.frame-order');
48 }
49 }
50 }
51 return $sequence;
52 }
53
54 /**
55 * Assigns the category used by gram.y to compare start and end boundaries.
56 */
57 public function rank(TerminalSequence $sequence, int $bound): int
58 {
59 $range = $sequence->range($bound);
60 if ($range === null) {
61 return 2;
62 }
63 $first = $sequence->nameAt($range[0]);
64 $last = $sequence->nameAt($range[1] - 1);
65 return match (true) {
66 $first === 'UNBOUNDED' => $last === 'PRECEDING' ? 0 : 4,
67 $last === 'PRECEDING' => 1,
68 $last === 'FOLLOWING' => 3,
69 default => 2,
70 };
71 }
72
73 /**
74 * Attaches the replacement endpoint to the original frame-bound occurrence for tracing.
75 */
76 public function unbounded(TerminalSequence $sequence, int $bound, string $direction): TerminalSequence
77 {
78 $range = $sequence->range($bound);
79 return $range === null ? $sequence : $sequence->replace($range[0], $range[1] - $range[0], [
80 $sequence->insertedFor('UNBOUNDED', $bound, 'postgresql.frame-order'),
81 $sequence->insertedFor($direction, $bound, 'postgresql.frame-order', 1),
82 ], 'postgresql.frame-order');
83 }
84}
85