packages/sql-fixture/tests/Unit/Plan/PlanParserTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Plan;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Test;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use SqlFixture\Plan\ColumnRef;
13use SqlFixture\Plan\FixturePlan;
14use SqlFixture\Plan\PlanParser;
15use SqlFixture\Plan\PlanSyntaxException;
16use SqlFixture\Plan\Relation;
17use SqlFixture\Plan\RelationKind;
18use SqlFixture\Plan\RelationSide;
19
20#[CoversClass(PlanParser::class)]
21#[UsesClass(FixturePlan::class)]
22#[UsesClass(Relation::class)]
23#[UsesClass(ColumnRef::class)]
24#[UsesClass(RelationKind::class)]
25#[UsesClass(RelationSide::class)]
26#[UsesClass(PlanSyntaxException::class)]
27#[CoversClass(\SqlFixture\Plan\Parsing\PlanStatements::class)]
28#[CoversClass(\SqlFixture\Plan\Parsing\RelationCursor::class)]
29#[CoversClass(\SqlFixture\Plan\Parsing\RelationReader::class)]
30#[UsesClass(\SqlFixture\Plan\PlanPrinter::class)]
31#[UsesClass(\SqlFixture\Plan\PlanStructureException::class)]
32#[UsesClass(\SqlFixture\Plan\Printing\PlanTables::class)]
33#[UsesClass(\SqlFixture\Plan\Printing\RelationGroups::class)]
34#[UsesClass(\SqlFixture\Plan\Printing\StatementPrinter::class)]
35#[UsesClass(\SqlFixture\Plan\Validation\PlanValidation::class)]
36#[UsesClass(\SqlFixture\Plan\Validation\TableName::class)]
37#[UsesClass(\SqlFixture\Plan\Exception\EmptyPlanException::class)]
38#[UsesClass(\SqlFixture\Plan\Exception\EmptyTableNameException::class)]
39#[UsesClass(\SqlFixture\Plan\Exception\MissingEndpointColumnsException::class)]
40#[UsesClass(\SqlFixture\Plan\Exception\InvalidTableNameException::class)]
41#[UsesClass(\SqlFixture\Plan\Exception\UnbalancedBracketsException::class)]
42#[UsesClass(\SqlFixture\Plan\Exception\UnexpectedPlanTokenException::class)]
43#[UsesClass(\SqlFixture\Plan\Exception\UnsupportedManyToManyException::class)]
44#[UsesClass(\SqlFixture\Plan\Exception\CompositeArityMismatchException::class)]
45#[UsesClass(\SqlFixture\Plan\Exception\DuplicateColumnBindingException::class)]
46#[UsesClass(\SqlFixture\Plan\Exception\CyclicDependencyException::class)]
47#[UsesClass(\SqlFixture\Plan\Exception\UnboundedSelfReferenceException::class)]
48final class PlanParserTest extends TestCase
49{
50 #[Test]
51 public function testParseABareTableNameIsAPlanWithNoRelations(): void
52 {
53 $plan = (new PlanParser())->parse('order');
54
55 self::assertSame([], $plan->relations);
56 self::assertSame(['order'], $plan->tables);
57 }
58
59 #[Test]
60 public function testReadsAOneToManyRelation(): void
61 {
62 $plan = (new PlanParser())->parse('order.id < order_detail.order_id');
63
64 self::assertCount(1, $plan->relations);
65 self::assertSame(RelationKind::OneToMany, $plan->relations[0]->kind);
66 self::assertSame('order.id', $plan->relations[0]->left->toString());
67 self::assertSame('order_detail.order_id', $plan->relations[0]->right->toString());
68 }
69
70 #[Test]
71 public function testReadsAManyToOneRelation(): void
72 {
73 $plan = (new PlanParser())->parse('order_detail.order_id > order.id');
74
75 self::assertSame(RelationKind::ManyToOne, $plan->relations[0]->kind);
76 self::assertSame('order', $plan->relations[0]->parent()->table);
77 }
78
79 #[Test]
80 public function testReadsAOneToOneRelation(): void
81 {
82 $plan = (new PlanParser())->parse('order.id - order_shipping.order_id');
83
84 self::assertSame(RelationKind::OneToOne, $plan->relations[0]->kind);
85 self::assertFalse($plan->relations[0]->childIsCollection());
86 }
87
88 #[Test]
89 public function testReadsACompositeEndpoint(): void
90 {
91 $plan = (new PlanParser())->parse('order.(shop_id, no) < order_detail.(shop_id, order_no)');
92
93 self::assertSame(['shop_id', 'no'], $plan->relations[0]->left->columns);
94 self::assertSame(['shop_id', 'order_no'], $plan->relations[0]->right->columns);
95 }
96
97 #[Test]
98 public function testAGroupedTargetExpandsToOneRelationPerEndpoint(): void
99 {
100 $plan = (new PlanParser())->parse('order.id < [order_detail.order_id, shipment.order_id]');
101
102 self::assertCount(2, $plan->relations);
103 self::assertSame('order_detail', $plan->relations[0]->right->table);
104 self::assertSame('shipment', $plan->relations[1]->right->table);
105 self::assertSame('order.id', $plan->relations[1]->left->toString());
106 }
107
108 #[Test]
109 public function testCommasSeparateRelations(): void
110 {
111 $plan = (new PlanParser())->parse('order.id < order_detail.order_id, order.customer_id > customer.id');
112
113 self::assertCount(2, $plan->relations);
114 self::assertSame(['order', 'order_detail', 'customer'], $plan->tables);
115 }
116
117 #[Test]
118 public function testNewlinesSeparateRelations(): void
119 {
120 $plan = (new PlanParser())->parse("order.id < order_detail.order_id\norder_detail.product_id > product.id");
121
122 self::assertCount(2, $plan->relations);
123 }
124
125 #[Test]
126 public function testSemicolonsSeparateRelations(): void
127 {
128 $plan = (new PlanParser())->parse('order.id < order_detail.order_id; order.customer_id > customer.id');
129
130 self::assertCount(2, $plan->relations);
131 }
132
133 #[Test]
134 public function testCommasInsideBracketsDoNotSeparateRelations(): void
135 {
136 $plan = (new PlanParser())->parse('order.(a, b) < [x.(a, b), y.(a, b)]');
137
138 self::assertCount(2, $plan->relations);
139 }
140
141 #[Test]
142 public function testTablesAreListedInFirstMentionedOrderWithoutRepeats(): void
143 {
144 $plan = (new PlanParser())->parse(
145 'order.id < order_detail.order_id, order_detail.product_id > product.id'
146 );
147
148 self::assertSame(['order', 'order_detail', 'product'], $plan->tables);
149 }
150
151 #[Test]
152 public function testAMarkerBeforeTheOperatorMarksTheLeftSideOptional(): void
153 {
154 $plan = (new PlanParser())->parse('order.id ?< order_detail.order_id');
155
156 self::assertTrue($plan->relations[0]->leftOptional);
157 self::assertFalse($plan->relations[0]->rightOptional);
158 }
159
160 #[Test]
161 public function testAMarkerAfterTheOperatorMarksTheRightSideOptional(): void
162 {
163 $plan = (new PlanParser())->parse('order_detail.order_id >? order.id');
164
165 self::assertTrue($plan->relations[0]->rightOptional);
166 self::assertTrue($plan->relations[0]->parentIsOptional());
167 }
168
169 #[Test]
170 #[DataProvider('providerQuotedForms')]
171 public function identifiersMayBeQuoted(string $plan): void
172 {
173 $parsed = (new PlanParser())->parse($plan);
174
175 self::assertSame('order.id', $parsed->relations[0]->left->toString());
176 }
177
178 /**
179 * @return array<string, array{string}>
180 */
181 public static function providerQuotedForms(): array
182 {
183 return [
184 'backticks' => ['`order`.`id` < order_detail.order_id'],
185 'double quotes' => ['"order"."id" < order_detail.order_id'],
186 'mixed' => ['`order`."id" < order_detail.order_id'],
187 ];
188 }
189
190 #[Test]
191 public function testSurroundingWhitespaceIsIgnored(): void
192 {
193 $plan = (new PlanParser())->parse(' order.id < order_detail.order_id ');
194
195 self::assertSame('order_detail.order_id', $plan->relations[0]->right->toString());
196 }
197
198 /**
199 * @return array<string, array{string, string}>
200 */
201 public static function providerMalformedPlans(): array
202 {
203 return [
204 'target without a column' => ['order.id < order_detail', "'.' after the table name"],
205 'unknown operator' => ['order.id !! order_detail.order_id', "one of '<', '>' or '-'"],
206 'missing operator' => ['order.id order_detail.order_id', "one of '<', '>' or '-'"],
207 'unclosed group' => ['order.id < [a.x, b.x', "',' or ']'"],
208 'unclosed composite' => ['order.(a, b < x.y', "',' or ')'"],
209 'trailing junk' => ['order.id < order_detail.order_id extra', 'the end of the relation'],
210 'arity mismatch' => ['order.(a, b) < order_detail.(c)', 'names 2 columns on one side'],
211 ];
212 }
213
214 #[Test]
215 public function testAGroupFollowedByAnotherRelationStillSplitsCorrectly(): void
216 {
217 $plan = (new PlanParser())->parse('a.id < [b.a_id, c.a_id], d.id < e.d_id');
218
219 self::assertCount(3, $plan->relations);
220 self::assertSame(['a', 'b', 'c', 'd', 'e'], $plan->tables);
221 }
222
223 #[Test]
224 public function testAnEmptyStatementBetweenTwoRelationsIsIgnored(): void
225 {
226 $plan = (new PlanParser())->parse('a.id < b.a_id,, c.id < d.c_id');
227
228 self::assertCount(2, $plan->relations);
229 }
230
231 #[Test]
232 public function testATableNameFollowedByRelationsKeepsThemAll(): void
233 {
234 $plan = (new PlanParser())->parse('audit_log, a.id < b.a_id');
235
236 self::assertCount(1, $plan->relations);
237 self::assertSame(['audit_log', 'a', 'b'], $plan->tables);
238 }
239
240 #[Test]
241 public function testWhitespaceInsideAGroupIsSkipped(): void
242 {
243 $plan = (new PlanParser())->parse('a.id < [ b.a_id , c.a_id ]');
244
245 self::assertCount(2, $plan->relations);
246 self::assertSame('c.a_id', $plan->relations[1]->right->toString());
247 }
248
249 #[Test]
250 public function testAStrayClosingBracketDoesNotStopLaterRelationsSplitting(): void
251 {
252 $plan = (new PlanParser())->parse('a.id < [b.a_id], c.id < d.c_id');
253
254 self::assertCount(2, $plan->relations);
255 }
256
257 #[Test]
258 public function testWhitespaceBetweenAnEndpointAndTheOperatorIsSkipped(): void
259 {
260 $plan = (new PlanParser())->parse('a.(x , y) < b.(p , q)');
261
262 self::assertSame(['x', 'y'], $plan->relations[0]->left->columns);
263 self::assertSame(['p', 'q'], $plan->relations[0]->right->columns);
264 }
265
266 #[Test]
267 public function testAStrayOpeningBracketDoesNotSwallowLaterRelations(): void
268 {
269 $plan = (new PlanParser())->parse('a.id < b.a_id; c.id < d.c_id');
270
271 self::assertCount(2, $plan->relations);
272 }
273
274}
275