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