packages/ztd-query-postgres/tests/Unit/Sql/Partition/PgSqlPartitionParserTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Sql\Partition;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use ZtdQuery\Platform\Postgres\Sql\Partition\PgSqlPartitionParser;
11use ZtdQuery\Schema\Partition\TablePartitionKey;
12use ZtdQuery\Schema\Partition\TablePartitionStrategy;
13
14#[CoversClass(PgSqlPartitionParser::class)]
15#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\PgSqlLexerProfile::class)]
16#[CoversClass(\ZtdQuery\Platform\Postgres\Sql\Partition\BoundPredicate::class)]
17#[CoversClass(\ZtdQuery\Platform\Postgres\Sql\Partition\ClauseTokens::class)]
18final class PgSqlPartitionParserTest extends TestCase
19{
20    public function testParseKeyParsesRangePartitionKeyAndBounds(): void
21    {
22        $parser = new PgSqlPartitionParser();
23        $key = $parser->parseKey('CREATE TABLE logs (log_date DATE) PARTITION BY RANGE (log_date)');
24
25        self::assertNotNull($key);
26        self::assertSame(TablePartitionStrategy::Range, $key->strategy);
27        self::assertSame(['log_date'], $key->expressions);
28
29        $relation = $parser->parseRelation(
30            "CREATE TABLE logs_2024 PARTITION OF public.logs FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')",
31            $key,
32        );
33
34        self::assertNotNull($relation);
35        self::assertSame('logs', $relation->parentTable);
36        self::assertSame(
37            "(log_date) >= '2024-01-01' AND (log_date) < '2025-01-01'",
38            $relation->predicate,
39        );
40    }
41
42    public function testParseRelationParsesListPartitionIncludingNull(): void
43    {
44        $parser = new PgSqlPartitionParser();
45        $key = new TablePartitionKey(TablePartitionStrategy::List, ['region']);
46
47        $relation = $parser->parseRelation(
48            "CREATE TABLE accounts_local PARTITION OF accounts FOR VALUES IN ('east', 'west', NULL)",
49            $key,
50        );
51
52        self::assertNotNull($relation);
53        self::assertSame("((region) IN ('east', 'west') OR (region) IS NULL)", $relation->predicate);
54    }
55
56    public function testParsesDefaultPartition(): void
57    {
58        $parser = new PgSqlPartitionParser();
59        $key = new TablePartitionKey(TablePartitionStrategy::List, ['region']);
60
61        $relation = $parser->parseRelation('CREATE TABLE accounts_other PARTITION OF accounts DEFAULT', $key);
62
63        self::assertNotNull($relation);
64        self::assertNull($relation->predicate);
65    }
66
67    public function testParsesFiniteMultiColumnRangeAsRowComparison(): void
68    {
69        $parser = new PgSqlPartitionParser();
70        $key = new TablePartitionKey(TablePartitionStrategy::Range, ['year', 'month']);
71
72        $relation = $parser->parseRelation(
73            'CREATE TABLE metrics_2024 PARTITION OF metrics FOR VALUES FROM (2024, 1) TO (2025, 1)',
74            $key,
75        );
76
77        self::assertNotNull($relation);
78        self::assertSame('ROW(year, month) >= ROW(2024, 1) AND ROW(year, month) < ROW(2025, 1)', $relation->predicate);
79    }
80
81    public function testParsesUnboundedRange(): void
82    {
83        $parser = new PgSqlPartitionParser();
84        $key = new TablePartitionKey(TablePartitionStrategy::Range, ['id']);
85
86        $relation = $parser->parseRelation(
87            'CREATE TABLE smallest PARTITION OF values_table FOR VALUES FROM (MINVALUE) TO (10)',
88            $key,
89        );
90
91        self::assertNotNull($relation);
92        self::assertSame('(id) < 10', $relation->predicate);
93    }
94
95    public function testRejectsHashAndMixedUnboundedRangeInsteadOfGuessing(): void
96    {
97        $parser = new PgSqlPartitionParser();
98
99        self::assertNull($parser->parseRelation(
100            'CREATE TABLE values_hash PARTITION OF values_table FOR VALUES WITH (MODULUS 4, REMAINDER 0)',
101            new TablePartitionKey(TablePartitionStrategy::Hash, ['id']),
102        ));
103        self::assertNull($parser->parseRelation(
104            'CREATE TABLE values_range PARTITION OF values_table FOR VALUES FROM (1, MINVALUE) TO (2, MAXVALUE)',
105            new TablePartitionKey(TablePartitionStrategy::Range, ['id', 'sequence']),
106        ));
107    }
108
109    public function testParentTableRejectsMalformedPartitionClauses(): void
110    {
111        $parser = new PgSqlPartitionParser();
112
113        self::assertNull($parser->parseKey('CREATE TABLE logs (id INTEGER)'));
114        self::assertNull($parser->parseKey('CREATE TABLE RANGE (id)'));
115        self::assertNull($parser->parseKey('CREATE TABLE logs (id INTEGER) PARTITION BY UNKNOWN (id)'));
116        self::assertNull($parser->parseKey('CREATE TABLE logs (id INTEGER) PARTITION BY RANGE ()'));
117        self::assertNull($parser->parseKey('CREATE TABLE logs (id INTEGER) PARTITION BY RANGE [id])'));
118        self::assertNull($parser->parentTable('CREATE TABLE logs (id INTEGER)'));
119        self::assertNull($parser->parentTable('CREATE TABLE child PARTITION OF [parent] DEFAULT'));
120        self::assertNull($parser->parseRelation(
121            'CREATE TABLE logs_2024 PARTITION OF logs FOR VALUES FROM () TO (10)',
122            new TablePartitionKey(TablePartitionStrategy::Range, ['id']),
123        ));
124    }
125
126    public function testParsesAllKeyStrategies(): void
127    {
128        $parser = new PgSqlPartitionParser();
129
130        self::assertSame(
131            TablePartitionStrategy::List,
132            $parser->parseKey('CREATE TABLE accounts (region TEXT) PARTITION BY LIST (region)')?->strategy,
133        );
134        self::assertSame(
135            TablePartitionStrategy::Hash,
136            $parser->parseKey('CREATE TABLE values_table (id INTEGER) PARTITION BY HASH (id)')?->strategy,
137        );
138    }
139
140    public function testRejectsMalformedBoundsIndependently(): void
141    {
142        $parser = new PgSqlPartitionParser();
143        $range = new TablePartitionKey(TablePartitionStrategy::Range, ['id']);
144        $list = new TablePartitionKey(TablePartitionStrategy::List, ['id']);
145
146        self::assertNull($parser->parseRelation(
147            'CREATE TABLE child PARTITION OF parent FOR VALUES IN (1)',
148            $range,
149        ));
150        self::assertNull($parser->parseRelation(
151            'CREATE TABLE child PARTITION OF parent FOR VALUES IN (1) TO (2)',
152            $range,
153        ));
154        self::assertNull($parser->parseRelation(
155            'CREATE TABLE child PARTITION OF parent FOR VALUES FROM (1) IN (2)',
156            $range,
157        ));
158        self::assertNull($parser->parseRelation(
159            'CREATE TABLE child PARTITION OF parent FOR VALUES FROM (1) TO ()',
160            $range,
161        ));
162        self::assertNull($parser->parseRelation(
163            'CREATE TABLE child PARTITION OF parent FOR VALUES FROM () TO (2)',
164            $range,
165        ));
166        self::assertNull($parser->parseRelation(
167            'CREATE TABLE child PARTITION OF parent FOR VALUES FROM (1, 2) TO (3, 4)',
168            $range,
169        ));
170        self::assertNull($parser->parseRelation(
171            'CREATE TABLE child PARTITION OF parent FOR VALUES FROM (MAXVALUE) TO (MAXVALUE)',
172            $range,
173        ));
174        self::assertNull($parser->parseRelation(
175            'CREATE TABLE child PARTITION OF parent FOR VALUES FROM (MINVALUE) TO (MINVALUE)',
176            $range,
177        ));
178        self::assertNull($parser->parseRelation(
179            'CREATE TABLE child PARTITION OF parent FOR VALUES FROM (1) TO (2)',
180            $list,
181        ));
182        self::assertNull($parser->parseRelation(
183            'CREATE TABLE child PARTITION OF parent FOR VALUES IN ()',
184            $list,
185        ));
186    }
187
188    public function testListPredicatesDistinguishValuesNullAndWhitespace(): void
189    {
190        $parser = new PgSqlPartitionParser();
191        $key = new TablePartitionKey(TablePartitionStrategy::List, ['region']);
192
193        self::assertSame(
194            "(region) IN ('east', 'west')",
195            $parser->parseRelation(
196                "CREATE TABLE east_west PARTITION OF accounts FOR VALUES IN ('east', 'west')",
197                $key,
198            )?->predicate,
199        );
200        self::assertSame(
201            '(region) IS NULL',
202            $parser->parseRelation(
203                'CREATE TABLE unset_region PARTITION OF accounts FOR VALUES IN (NULL)',
204                $key,
205            )?->predicate,
206        );
207        self::assertSame(
208            "((region) IN ('east') OR (region) IS NULL)",
209            $parser->parseRelation(
210                "CREATE TABLE unset_region PARTITION OF accounts FOR VALUES IN (NULL, 'east')",
211                $key,
212            )?->predicate,
213        );
214        self::assertSame(
215            '(id) < 10',
216            $parser->parseRelation(
217                'CREATE TABLE low_values PARTITION OF values_table FOR VALUES FROM ( minvalue ) TO (10)',
218                new TablePartitionKey(TablePartitionStrategy::Range, ['id']),
219            )?->predicate,
220        );
221    }
222
223    public function testIgnoresNestedPartitionKeywordsWhenFindingTopLevelClauses(): void
224    {
225        $parser = new PgSqlPartitionParser();
226        $key = $parser->parseKey(
227            'CREATE TABLE logs (id INTEGER CHECK (PARTITION BY)) PARTITION BY RANGE (id)',
228        );
229
230        self::assertNotNull($key);
231        self::assertSame(TablePartitionStrategy::Range, $key->strategy);
232        self::assertSame(['id'], $key->expressions);
233        self::assertSame(
234            '(id) IN (1)',
235            $parser->parseRelation(
236                'CREATE TABLE child (id INTEGER DEFAULT 0) PARTITION OF parent FOR VALUES IN (1)',
237                new TablePartitionKey(TablePartitionStrategy::List, ['id']),
238            )?->predicate,
239        );
240    }
241
242    public function testSkipsIncompleteKeywordPairBeforeThePartitionClause(): void
243    {
244        $key = (new PgSqlPartitionParser())->parseKey(
245            'CREATE TABLE logs (id INTEGER) PARTITION WRONG RANGE (wrong) PARTITION BY RANGE (id)',
246        );
247
248        self::assertNotNull($key);
249        self::assertSame(TablePartitionStrategy::Range, $key->strategy);
250        self::assertSame(['id'], $key->expressions);
251    }
252
253    public function testNormalizesUnquotedParentNamesAndPreservesQuotedNames(): void
254    {
255        $parser = new PgSqlPartitionParser();
256
257        self::assertSame(
258            'logs',
259            $parser->parentTable('CREATE TABLE child PARTITION OF Public.Logs DEFAULT'),
260        );
261        self::assertSame(
262            'Logs',
263            $parser->parentTable('CREATE TABLE child PARTITION OF public."Logs" DEFAULT'),
264        );
265    }
266
267
268}
269