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