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