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)]
48#[UsesClass(\SqlFixture\Plan\Choice\ChoiceValidation::class)]
49#[UsesClass(\SqlFixture\Plan\Choice\PlanContents::class)]
50final class PlanParserTest extends TestCase
51{
52 #[Test]
53 public function testParseABareTableNameIsAPlanWithNoRelations(): void
54 {
55 $plan = (new PlanParser())->parse('order');
56
57 self::assertSame([], $plan->relations);
58 self::assertSame(['order'], $plan->tables);
59 }
60
61 #[Test]
62 public function testReadsAOneToManyRelation(): void
63 {
64 $plan = (new PlanParser())->parse('order.id < order_detail.order_id');
65
66 self::assertCount(1, $plan->relations);
67 self::assertSame(RelationKind::OneToMany, $plan->relations[0]->kind);
68 self::assertSame('order.id', $plan->relations[0]->left->toString());
69 self::assertSame('order_detail.order_id', $plan->relations[0]->right->toString());
70 }
71
72 #[Test]
73 public function testReadsAManyToOneRelation(): void
74 {
75 $plan = (new PlanParser())->parse('order_detail.order_id > order.id');
76
77 self::assertSame(RelationKind::ManyToOne, $plan->relations[0]->kind);
78 self::assertSame('order', $plan->relations[0]->parent()->table);
79 }
80
81 #[Test]
82 public function testReadsAOneToOneRelation(): void
83 {
84 $plan = (new PlanParser())->parse('order.id - order_shipping.order_id');
85
86 self::assertSame(RelationKind::OneToOne, $plan->relations[0]->kind);
87 self::assertFalse($plan->relations[0]->childIsCollection());
88 }
89
90 #[Test]
91 public function testReadsACompositeEndpoint(): void
92 {
93 $plan = (new PlanParser())->parse('order.(shop_id, no) < order_detail.(shop_id, order_no)');
94
95 self::assertSame(['shop_id', 'no'], $plan->relations[0]->left->columns);
96 self::assertSame(['shop_id', 'order_no'], $plan->relations[0]->right->columns);
97 }
98
99 #[Test]
100 public function testAGroupedTargetExpandsToOneRelationPerEndpoint(): void
101 {
102 $plan = (new PlanParser())->parse('order.id < [order_detail.order_id, shipment.order_id]');
103
104 self::assertCount(2, $plan->relations);
105 self::assertSame('order_detail', $plan->relations[0]->right->table);
106 self::assertSame('shipment', $plan->relations[1]->right->table);
107 self::assertSame('order.id', $plan->relations[1]->left->toString());
108 }
109
110 #[Test]
111 public function testCommasSeparateRelations(): void
112 {
113 $plan = (new PlanParser())->parse('order.id < order_detail.order_id, order.customer_id > customer.id');
114
115 self::assertCount(2, $plan->relations);
116 self::assertSame(['order', 'order_detail', 'customer'], $plan->tables);
117 }
118
119 #[Test]
120 public function testNewlinesSeparateRelations(): void
121 {
122 $plan = (new PlanParser())->parse("order.id < order_detail.order_id\norder_detail.product_id > product.id");
123
124 self::assertCount(2, $plan->relations);
125 }
126
127 #[Test]
128 public function testSemicolonsSeparateRelations(): void
129 {
130 $plan = (new PlanParser())->parse('order.id < order_detail.order_id; order.customer_id > customer.id');
131
132 self::assertCount(2, $plan->relations);
133 }
134
135 #[Test]
136 public function testCommasInsideBracketsDoNotSeparateRelations(): void
137 {
138 $plan = (new PlanParser())->parse('order.(a, b) < [x.(a, b), y.(a, b)]');
139
140 self::assertCount(2, $plan->relations);
141 }
142
143 #[Test]
144 public function testTablesAreListedInFirstMentionedOrderWithoutRepeats(): void
145 {
146 $plan = (new PlanParser())->parse(
147 'order.id < order_detail.order_id, order_detail.product_id > product.id'
148 );
149
150 self::assertSame(['order', 'order_detail', 'product'], $plan->tables);
151 }
152
153 #[Test]
154 public function testAMarkerBeforeTheOperatorMarksTheLeftSideOptional(): void
155 {
156 $plan = (new PlanParser())->parse('order.id ?< order_detail.order_id');
157
158 self::assertTrue($plan->relations[0]->leftOptional);
159 self::assertFalse($plan->relations[0]->rightOptional);
160 }
161
162 #[Test]
163 public function testAMarkerAfterTheOperatorMarksTheRightSideOptional(): void
164 {
165 $plan = (new PlanParser())->parse('order_detail.order_id >? order.id');
166
167 self::assertTrue($plan->relations[0]->rightOptional);
168 self::assertTrue($plan->relations[0]->parentIsOptional());
169 }
170
171 #[Test]
172 #[DataProvider('providerQuotedForms')]
173 public function identifiersMayBeQuoted(string $plan): void
174 {
175 $parsed = (new PlanParser())->parse($plan);
176
177 self::assertSame('order.id', $parsed->relations[0]->left->toString());
178 }
179
180 /**
181 * @return array<string, array{string}>
182 */
183 public static function providerQuotedForms(): array
184 {
185 return [
186 'backticks' => ['`order`.`id` < order_detail.order_id'],
187 'double quotes' => ['"order"."id" < order_detail.order_id'],
188 'mixed' => ['`order`."id" < order_detail.order_id'],
189 ];
190 }
191
192 #[Test]
193 public function testSurroundingWhitespaceIsIgnored(): void
194 {
195 $plan = (new PlanParser())->parse(' order.id < order_detail.order_id ');
196
197 self::assertSame('order_detail.order_id', $plan->relations[0]->right->toString());
198 }
199
200 /**
201 * @return array<string, array{string, string}>
202 */
203 public static function providerMalformedPlans(): array
204 {
205 return [
206 'target without a column' => ['order.id < order_detail', "'.' after the table name"],
207 'unknown operator' => ['order.id !! order_detail.order_id', "one of '<', '>' or '-'"],
208 'missing operator' => ['order.id order_detail.order_id', "one of '<', '>' or '-'"],
209 'unclosed group' => ['order.id < [a.x, b.x', "',' or ']'"],
210 'unclosed composite' => ['order.(a, b < x.y', "',' or ')'"],
211 'trailing junk' => ['order.id < order_detail.order_id extra', 'the end of the relation'],
212 'arity mismatch' => ['order.(a, b) < order_detail.(c)', 'names 2 columns on one side'],
213 ];
214 }
215
216 #[Test]
217 public function testAGroupFollowedByAnotherRelationStillSplitsCorrectly(): void
218 {
219 $plan = (new PlanParser())->parse('a.id < [b.a_id, c.a_id], d.id < e.d_id');
220
221 self::assertCount(3, $plan->relations);
222 self::assertSame(['a', 'b', 'c', 'd', 'e'], $plan->tables);
223 }
224
225 #[Test]
226 public function testAnEmptyStatementBetweenTwoRelationsIsIgnored(): void
227 {
228 $plan = (new PlanParser())->parse('a.id < b.a_id,, c.id < d.c_id');
229
230 self::assertCount(2, $plan->relations);
231 }
232
233 #[Test]
234 public function testATableNameFollowedByRelationsKeepsThemAll(): void
235 {
236 $plan = (new PlanParser())->parse('audit_log, a.id < b.a_id');
237
238 self::assertCount(1, $plan->relations);
239 self::assertSame(['audit_log', 'a', 'b'], $plan->tables);
240 }
241
242 #[Test]
243 public function testWhitespaceInsideAGroupIsSkipped(): void
244 {
245 $plan = (new PlanParser())->parse('a.id < [ b.a_id , c.a_id ]');
246
247 self::assertCount(2, $plan->relations);
248 self::assertSame('c.a_id', $plan->relations[1]->right->toString());
249 }
250
251 #[Test]
252 public function testAStrayClosingBracketDoesNotStopLaterRelationsSplitting(): void
253 {
254 $plan = (new PlanParser())->parse('a.id < [b.a_id], c.id < d.c_id');
255
256 self::assertCount(2, $plan->relations);
257 }
258
259 #[Test]
260 public function testWhitespaceBetweenAnEndpointAndTheOperatorIsSkipped(): void
261 {
262 $plan = (new PlanParser())->parse('a.(x , y) < b.(p , q)');
263
264 self::assertSame(['x', 'y'], $plan->relations[0]->left->columns);
265 self::assertSame(['p', 'q'], $plan->relations[0]->right->columns);
266 }
267
268 #[Test]
269 public function testAStrayOpeningBracketDoesNotSwallowLaterRelations(): void
270 {
271 $plan = (new PlanParser())->parse('a.id < b.a_id; c.id < d.c_id');
272
273 self::assertCount(2, $plan->relations);
274 }
275
276}
277