packages/ztd-query-postgres/src/Sql/Sampling/SampleClause.php

1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Platform\Postgres\Sql\Sampling;
6
7use ZtdQuery\Exception\UnsupportedSqlException;
8use ZtdQuery\Platform\Postgres\Sql\PgSqlLexerProfile;
9use ZtdQuery\Sql\SqlToken;
10use ZtdQuery\Sql\SqlTokenStream;
11
12/**
13 * Sample clause operations for PostgreSQL sampling.
14 *
15 * @visibility root
16 */
17final class SampleClause
18{
19    /**
20     * @param list<SqlToken> $tokens
21     * @param array{name: string, start: int, unqualifiedStart: int, end: int} $reference
22     * @throws UnsupportedSqlException
23     */
24    public function parseSample(
25        string $sql,
26        array $tokens,
27        array $reference,
28        int $sampleIndex,
29        SqlToken $referenceToken,
30    ): PgSqlTableSample {
31        $methodToken = $tokens[$sampleIndex + 1] ?? null;
32        $method = $methodToken instanceof SqlToken
33            ? PgSqlTableSampleMethod::tryFrom(strtoupper($methodToken->text))
34            : null;
35        if ($method === null) {
36            throw new UnsupportedSqlException($sql, 'TABLESAMPLE method not supported for shadow relations');
37        }
38
39        $openIndex = $sampleIndex + 2;
40        $open = $tokens[$openIndex] ?? null;
41        if (!$open instanceof SqlToken || !(new SampleTokens())->isOpeningParenthesis($open, $referenceToken)) {
42            throw new UnsupportedSqlException($sql, 'Malformed TABLESAMPLE opening parenthesis');
43        }
44        $closeIndex = (new SampleTokens())->closingParenthesisIndex($tokens, $openIndex);
45        if ($closeIndex === null) {
46            throw new UnsupportedSqlException($sql, 'Malformed TABLESAMPLE closing parenthesis');
47        }
48        $close = $tokens[$closeIndex];
49        $percentageSql = trim(substr($sql, $open->endOffset(), $close->offset - $open->endOffset()));
50        if ($percentageSql === '' || count(SqlTokenStream::tokenize($percentageSql, PgSqlLexerProfile::create())->splitTopLevel()) !== 1) {
51            throw new UnsupportedSqlException($sql, 'TABLESAMPLE requires one percentage expression');
52        }
53
54        $repeatable = $this->repeatable($sql, $tokens, $referenceToken, $closeIndex);
55
56        $sampleToken = $tokens[$sampleIndex];
57        $aliasSql = $this->aliasSql($sql, $tokens, $reference, $referenceToken, $sampleToken);
58
59        return new PgSqlTableSample(
60            $reference['name'],
61            substr($sql, $reference['start'], $reference['end'] - $reference['start']),
62            $aliasSql,
63            $method,
64            $percentageSql,
65            $repeatable['seed'],
66            $reference['start'],
67            $repeatable['end'],
68        );
69    }
70    /**
71     * Reads the optional REPEATABLE seed following the percentage expression.
72     * @param list<SqlToken> $tokens
73     * @return array{seed: string|null, end: int}
74     * @throws UnsupportedSqlException
75     */
76    public function repeatable(string $sql, array $tokens, SqlToken $referenceToken, int $closeIndex): array
77    {
78        $seedSql = null;
79        $endOffset = $tokens[$closeIndex]->endOffset();
80        $repeatable = $tokens[$closeIndex + 1] ?? null;
81        if ($repeatable?->isKeyword('REPEATABLE') === true
82            && (new SampleTokens())->sameLevel($repeatable, $referenceToken)
83        ) {
84            $seedOpenIndex = $closeIndex + 2;
85            $seedOpen = $tokens[$seedOpenIndex] ?? null;
86            if (!$seedOpen instanceof SqlToken || !(new SampleTokens())->isOpeningParenthesis($seedOpen, $referenceToken)) {
87                throw new UnsupportedSqlException($sql, 'Malformed TABLESAMPLE REPEATABLE opening parenthesis');
88            }
89            $seedCloseIndex = (new SampleTokens())->closingParenthesisIndex($tokens, $seedOpenIndex);
90            if ($seedCloseIndex === null) {
91                throw new UnsupportedSqlException($sql, 'Malformed TABLESAMPLE REPEATABLE closing parenthesis');
92            }
93            $seedClose = $tokens[$seedCloseIndex];
94            $seedSql = trim(substr($sql, $seedOpen->endOffset(), $seedClose->offset - $seedOpen->endOffset()));
95            if ($seedSql === '' || count(SqlTokenStream::tokenize($seedSql, PgSqlLexerProfile::create())->splitTopLevel()) !== 1) {
96                throw new UnsupportedSqlException($sql, 'TABLESAMPLE REPEATABLE requires one seed expression');
97            }
98            $endOffset = $seedClose->endOffset();
99        }
100
101        return ['seed' => $seedSql, 'end' => $endOffset];
102    }
103
104    /**
105     * Preserves the alias between a relation or inheritance marker and TABLESAMPLE.
106     * @param list<SqlToken> $tokens
107     * @param array{name: string, start: int, unqualifiedStart: int, end: int} $reference
108     */
109    public function aliasSql(string $sql, array $tokens, array $reference, SqlToken $referenceToken, SqlToken $sampleToken): string
110    {
111        $aliasStart = $reference['end'];
112        $inheritanceMarker = (new SampleTokens())->tokenAfter($tokens, $referenceToken);
113        if ($inheritanceMarker->text === '*') {
114            $aliasStart = $inheritanceMarker->endOffset();
115        }
116        $aliasSql = trim(substr($sql, $aliasStart, $sampleToken->offset - $aliasStart));
117
118        return $aliasSql;
119    }
120}
121