packages/sql-fixture/tests/Unit/Plan/FixturePlanTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Plan;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\Test;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlFixture\Plan\ColumnRef;
12use SqlFixture\Plan\FixturePlan;
13use SqlFixture\Plan\PlanParser;
14use SqlFixture\Plan\PlanPrinter;
15use SqlFixture\Plan\PlanStructureException;
16use SqlFixture\Plan\PlanSyntaxException;
17use SqlFixture\Plan\Relation;
18use SqlFixture\Plan\RelationKind;
19use SqlFixture\Plan\RelationSide;
20
21#[CoversClass(FixturePlan::class)]
22#[UsesClass(PlanParser::class)]
23#[UsesClass(PlanPrinter::class)]
24#[UsesClass(Relation::class)]
25#[UsesClass(ColumnRef::class)]
26#[UsesClass(RelationKind::class)]
27#[UsesClass(RelationSide::class)]
28#[UsesClass(PlanSyntaxException::class)]
29#[UsesClass(PlanStructureException::class)]
30#[CoversClass(\SqlFixture\Plan\Validation\PlanValidation::class)]
31#[CoversClass(\SqlFixture\Plan\Validation\TableName::class)]
32#[UsesClass(\SqlFixture\Plan\Parsing\PlanStatements::class)]
33#[UsesClass(\SqlFixture\Plan\Parsing\RelationCursor::class)]
34#[UsesClass(\SqlFixture\Plan\Parsing\RelationReader::class)]
35#[UsesClass(\SqlFixture\Plan\Printing\PlanTables::class)]
36#[UsesClass(\SqlFixture\Plan\Printing\RelationGroups::class)]
37#[UsesClass(\SqlFixture\Plan\Printing\StatementPrinter::class)]
38#[UsesClass(\SqlFixture\Plan\Exception\EmptyPlanException::class)]
39#[UsesClass(\SqlFixture\Plan\Exception\EmptyTableNameException::class)]
40#[UsesClass(\SqlFixture\Plan\Exception\MissingEndpointColumnsException::class)]
41#[UsesClass(\SqlFixture\Plan\Exception\InvalidTableNameException::class)]
42#[UsesClass(\SqlFixture\Plan\Exception\UnbalancedBracketsException::class)]
43#[UsesClass(\SqlFixture\Plan\Exception\UnexpectedPlanTokenException::class)]
44#[UsesClass(\SqlFixture\Plan\Exception\UnsupportedManyToManyException::class)]
45#[UsesClass(\SqlFixture\Plan\Exception\CompositeArityMismatchException::class)]
46#[UsesClass(\SqlFixture\Plan\Exception\DuplicateColumnBindingException::class)]
47#[UsesClass(\SqlFixture\Plan\Exception\CyclicDependencyException::class)]
48#[UsesClass(\SqlFixture\Plan\Exception\UnboundedSelfReferenceException::class)]
49#[UsesClass(\SqlFixture\Plan\Choice\ChoiceCase::class)]
50#[UsesClass(\SqlFixture\Plan\Choice\ChoiceDefinitionException::class)]
51#[UsesClass(\SqlFixture\Plan\Choice\ChoiceLiteral::class)]
52#[UsesClass(\SqlFixture\Plan\Choice\ChoiceSyntax::class)]
53#[UsesClass(\SqlFixture\Plan\Choice\ChoiceValidation::class)]
54#[UsesClass(\SqlFixture\Plan\Choice\PlanContents::class)]
55#[UsesClass(\SqlFixture\Plan\RelationChoice::class)]
56final class FixturePlanTest extends TestCase
57{
58 #[Test]
59 public function testFromReadsTheRelationString(): void
60 {
61 $plan = FixturePlan::from('order.id < order_detail.order_id');
62
63 self::assertCount(1, $plan->relations);
64 self::assertSame(['order', 'order_detail'], $plan->tables);
65 }
66
67 #[Test]
68 public function testFromAcceptsAPlanAndCopiesIt(): void
69 {
70 $original = FixturePlan::from('order.id < order_detail.order_id');
71 $copy = FixturePlan::from($original);
72
73 self::assertNotSame($original, $copy);
74 self::assertSame($original->toString(), $copy->toString());
75 }
76
77 #[Test]
78 public function testToStringWritesThePlanBack(): void
79 {
80 $plan = FixturePlan::from('order.id<order_detail.order_id');
81
82 self::assertSame('order.id < order_detail.order_id', $plan->toString());
83 }
84
85 #[Test]
86 public function testCastingToStringWritesThePlanBack(): void
87 {
88 self::assertSame(
89 'order.id < order_detail.order_id',
90 (string) FixturePlan::from('order.id < order_detail.order_id')
91 );
92 }
93
94 #[Test]
95 public function testTableStartsAPlanWithNoRelations(): void
96 {
97 $plan = FixturePlan::table('order');
98
99 self::assertSame([], $plan->relations);
100 self::assertSame(['order'], $plan->tables);
101 self::assertSame('order', $plan->toString());
102 }
103
104 #[Test]
105 public function testWithOneToManyAddsARelation(): void
106 {
107 $plan = FixturePlan::table('order')->withOneToMany('order.id', 'order_detail.order_id');
108
109 self::assertSame('order.id < order_detail.order_id', $plan->toString());
110 }
111
112 #[Test]
113 public function testWithManyToOneAddsARelation(): void
114 {
115 $plan = FixturePlan::table('order')->withManyToOne('order.customer_id', 'customer.id');
116
117 self::assertSame('order.customer_id > customer.id', $plan->toString());
118 }
119
120 #[Test]
121 public function testWithManyToOneCanMarkTheParentOptional(): void
122 {
123 $plan = FixturePlan::table('order')->withManyToOne('order.customer_id', 'customer.id', true);
124
125 self::assertSame('order.customer_id >? customer.id', $plan->toString());
126 self::assertTrue($plan->relations[0]->parentIsOptional());
127 }
128
129 #[Test]
130 public function testWithOneToOneAddsARelation(): void
131 {
132 $plan = FixturePlan::table('order')->withOneToOne('order.id', 'order_shipping.order_id');
133
134 self::assertSame('order.id - order_shipping.order_id', $plan->toString());
135 }
136
137 #[Test]
138 public function testWithRelationAcceptsAColumnRefForCompositeKeys(): void
139 {
140 $plan = FixturePlan::table('order')->withOneToMany(
141 ColumnRef::of('order', 'shop_id', 'no'),
142 ColumnRef::of('order_detail', 'shop_id', 'order_no')
143 );
144
145 self::assertSame('order.(shop_id, no) < order_detail.(shop_id, order_no)', $plan->toString());
146 }
147
148 #[Test]
149 public function testWithTableNamesATableThatHasNoRelationYet(): void
150 {
151 $plan = FixturePlan::table('order')->withTable('audit_log');
152
153 self::assertSame(['order', 'audit_log'], $plan->tables);
154 }
155
156 #[Test]
157 public function testTablesAreNotRepeated(): void
158 {
159 $plan = FixturePlan::table('order')
160 ->withOneToMany('order.id', 'order_detail.order_id')
161 ->withOneToMany('order.id', 'shipment.order_id');
162
163 self::assertSame(['order', 'order_detail', 'shipment'], $plan->tables);
164 }
165
166 #[Test]
167 public function testEveryBuilderMethodReturnsANewInstance(): void
168 {
169 $base = FixturePlan::table('order');
170
171 self::assertNotSame($base, $base->withOneToMany('order.id', 'order_detail.order_id'));
172 self::assertNotSame($base, $base->withTable('customer'));
173 self::assertSame([], $base->relations);
174 }
175
176 #[Test]
177 public function testSubjectTableTheSubjectIsTheFirstTableNamed(): void
178 {
179 $plan = FixturePlan::from('order.id < order_detail.order_id, order.customer_id > customer.id');
180
181 self::assertSame('order', $plan->subjectTable());
182 }
183
184 #[Test]
185 public function testAnEmptyPlanHasNoSubject(): void
186 {
187 self::assertNull((new FixturePlan())->subjectTable());
188 }
189
190 #[Test]
191 public function testTheSubjectIsNotNecessarilyGeneratedFirst(): void
192 {
193 $plan = FixturePlan::from('order_detail.order_id > order.id');
194
195 self::assertSame('order_detail', $plan->subjectTable());
196 self::assertSame(['order', 'order_detail'], $plan->generationOrder);
197 }
198
199 #[Test]
200 public function testGenerationOrderPutsEveryParentBeforeItsChildren(): void
201 {
202 $plan = FixturePlan::from(
203 'order.id < order_detail.order_id, order_detail.product_id > product.id'
204 );
205
206 self::assertSame(['order', 'product', 'order_detail'], $plan->generationOrder);
207 }
208
209 #[Test]
210 public function testGenerationOrderCoversTablesThatStandAlone(): void
211 {
212 $plan = FixturePlan::from('order.id < order_detail.order_id, audit_log');
213
214 self::assertContains('audit_log', $plan->generationOrder);
215 self::assertCount(3, $plan->generationOrder);
216 }
217
218 #[Test]
219 public function testGenerationOrderHandlesSeveralIndependentComponents(): void
220 {
221 $plan = FixturePlan::from('a.id < b.a_id, c.id < d.c_id');
222
223 self::assertSame(['a', 'c', 'b', 'd'], $plan->generationOrder);
224 }
225
226 #[Test]
227 public function testRootsAreTheTablesNothingHasToPrecede(): void
228 {
229 $plan = FixturePlan::from('b.a_id > a.id, b.c_id > c.id');
230
231 self::assertSame(['a', 'c'], $plan->roots());
232 }
233
234 #[Test]
235 public function testDependenciesOfSelectsRelationsWhereTheTableIsTheChild(): void
236 {
237 $plan = FixturePlan::from(
238 'order.id < order_detail.order_id, order_detail.product_id > product.id'
239 );
240
241 self::assertCount(2, $plan->dependenciesOf('order_detail'));
242 self::assertSame([], $plan->dependenciesOf('order'));
243 self::assertSame([], $plan->dependenciesOf('product'));
244 }
245
246 #[Test]
247 public function testDependentsOfSelectsRelationsWhereTheTableIsTheParent(): void
248 {
249 $plan = FixturePlan::from(
250 'order.id < order_detail.order_id, order_detail.product_id > product.id'
251 );
252
253 self::assertCount(1, $plan->dependentsOf('order'));
254 self::assertSame('order_detail', $plan->dependentsOf('order')[0]->child()->table);
255 self::assertCount(1, $plan->dependentsOf('product'));
256 self::assertSame([], $plan->dependentsOf('order_detail'));
257 }
258
259 #[Test]
260 public function testAnOptionalSelfReferenceIsAllowed(): void
261 {
262 $plan = FixturePlan::from('category.id <? category.parent_id');
263
264 self::assertSame(['category'], $plan->generationOrder);
265 }
266
267 #[Test]
268 public function testTwoForeignKeysBetweenTheSameTablesAreAllowed(): void
269 {
270 $plan = FixturePlan::from('a.id < b.a_id, a.code < b.a_code');
271
272 self::assertCount(2, $plan->relations);
273 self::assertSame(['a', 'b'], $plan->generationOrder);
274 }
275
276 #[Test]
277 public function testTheStringAndTheObjectDescribeTheSamePlan(): void
278 {
279 $written = 'order.id < order_detail.order_id, order.customer_id > customer.id';
280 $built = FixturePlan::table('order')
281 ->withOneToMany('order.id', 'order_detail.order_id')
282 ->withManyToOne('order.customer_id', 'customer.id');
283
284 self::assertSame($written, $built->toString());
285 self::assertSame($written, FixturePlan::from($written)->toString());
286 }
287
288 #[Test]
289 public function testAPlanCanBeDeclaredAsAType(): void
290 {
291 $declaredPlan = new class () extends FixturePlan {
292 public function __construct()
293 {
294 parent::__construct(
295 Relation::oneToMany('order.id', 'order_detail.order_id'),
296 Relation::manyToOne('order.customer_id', 'customer.id'),
297 );
298 }
299 };
300 $plan = $declaredPlan;
301
302 self::assertSame(['order', 'order_detail', 'customer'], $plan->tables);
303 }
304
305 #[Test]
306 public function testADeclaredPlanWritesOutAsTheSameRelationString(): void
307 {
308 $declaredPlan = new class () extends FixturePlan {
309 public function __construct()
310 {
311 parent::__construct(
312 Relation::oneToMany('order.id', 'order_detail.order_id'),
313 Relation::manyToOne('order.customer_id', 'customer.id'),
314 );
315 }
316 };
317 self::assertSame(
318 'order.id < order_detail.order_id, order.customer_id > customer.id',
319 ($declaredPlan)->toString()
320 );
321 }
322
323 #[Test]
324 public function testADeclaredPlanEqualsTheParsedString(): void
325 {
326 $declaredPlan = new class () extends FixturePlan {
327 public function __construct()
328 {
329 parent::__construct(
330 Relation::oneToMany('order.id', 'order_detail.order_id'),
331 Relation::manyToOne('order.customer_id', 'customer.id'),
332 );
333 }
334 };
335 $declared = $declaredPlan;
336 $parsed = FixturePlan::from('order.id < order_detail.order_id, order.customer_id > customer.id');
337
338 self::assertSame($parsed->toString(), $declared->toString());
339 self::assertSame($parsed->tables, $declared->tables);
340 self::assertEquals($parsed->relations, $declared->relations);
341 }
342
343 #[Test]
344 public function testOfAcceptsRelationsWithoutAString(): void
345 {
346 $plan = new FixturePlan(
347 Relation::oneToMany('order.id', 'order_detail.order_id'),
348 Relation::manyToOne('order.customer_id', 'customer.id'),
349 );
350
351 self::assertSame(
352 'order.id < order_detail.order_id, order.customer_id > customer.id',
353 $plan->toString()
354 );
355 }
356
357 #[Test]
358 public function testAStringPartNamesAStandaloneTable(): void
359 {
360 $plan = new FixturePlan(Relation::oneToMany('order.id', 'order_detail.order_id'), 'audit_log');
361
362 self::assertSame(['order', 'order_detail', 'audit_log'], $plan->tables);
363 }
364
365 #[Test]
366 public function testAlteringADeclaredPlanGivesAPlainPlan(): void
367 {
368 $declaredPlan = new class () extends FixturePlan {
369 public function __construct()
370 {
371 parent::__construct(
372 Relation::oneToMany('order.id', 'order_detail.order_id'),
373 Relation::manyToOne('order.customer_id', 'customer.id'),
374 );
375 }
376 };
377 $plan = ($declaredPlan)->withTable('audit_log');
378
379 self::assertNotInstanceOf($declaredPlan::class, $plan);
380 self::assertSame(['order', 'order_detail', 'customer', 'audit_log'], $plan->tables);
381 }
382
383 #[Test]
384 public function testPartsSpreadFromAKeyedArrayAreStillReadInOrder(): void
385 {
386 $plan = new FixturePlan(...[
387 'first' => Relation::oneToMany('a.id', 'b.a_id'),
388 'second' => 'audit_log',
389 ]);
390
391 self::assertSame(['a', 'b', 'audit_log'], $plan->tables);
392 self::assertSame('a.id < b.a_id, audit_log', $plan->toString());
393 }
394
395 #[Test]
396 public function testATableNameIsTakenWithoutSurroundingSpace(): void
397 {
398 self::assertSame(['order'], (new FixturePlan(' order '))->tables);
399 }
400
401 #[Test]
402 public function testAQuotedTableNameLosesItsQuotes(): void
403 {
404 self::assertSame(['order'], (new FixturePlan('`order`'))->tables);
405 self::assertSame(['order'], (new FixturePlan('"order"'))->tables);
406 }
407
408 #[Test]
409 public function testDependenciesOfReturnsAListEvenWhenTheMatchIsNotFirst(): void
410 {
411 $plan = FixturePlan::from('a.id < b.a_id, c.id < d.c_id');
412
413 self::assertSame([$plan->relations[1]], $plan->dependenciesOf('d'));
414 }
415
416 #[Test]
417 public function testDependentsOfReturnsAListEvenWhenTheMatchIsNotFirst(): void
418 {
419 $plan = FixturePlan::from('a.id < b.a_id, c.id < d.c_id');
420
421 self::assertSame([$plan->relations[1]], $plan->dependentsOf('c'));
422 }
423
424 #[Test]
425 public function testRootsReturnsAListEvenWhenTheFirstTableIsNotOne(): void
426 {
427 $plan = FixturePlan::from('b.a_id > a.id');
428
429 self::assertSame(['a'], $plan->roots());
430 }
431
432 #[Test]
433 public function testWithRelationAddsARelationDirectly(): void
434 {
435 $plan = FixturePlan::table('order')->withRelation(Relation::oneToMany('order.id', 'order_detail.order_id'));
436
437 self::assertSame('order.id < order_detail.order_id', $plan->toString());
438 }
439
440 #[Test]
441 public function testAPlanBuiltFromAKeyedSpreadCanStillBeExtended(): void
442 {
443 $plan = (new FixturePlan(...['first' => Relation::oneToMany('a.id', 'b.a_id')]))->withTable('audit_log');
444
445 self::assertSame(['a', 'b', 'audit_log'], $plan->tables);
446 }
447
448 public function testWithChoiceKeepsEveryPotentialTableInOrder(): void
449 {
450 $choice = \SqlFixture\Plan\RelationChoice::on('comments.kind')->when('post', Relation::manyToOne('comments.id', 'posts.id'))->when('video', Relation::manyToOne('comments.id', 'videos.id'));
451 $base = FixturePlan::table('comments');
452 $plan = $base->withChoice($choice);
453 self::assertSame([], $base->choices);
454 self::assertSame([$choice], $plan->choices);
455 self::assertSame(['comments', 'posts', 'videos'], $plan->tables);
456 self::assertEquals($plan, FixturePlan::from($plan));
457 self::assertSame($plan->toString(), FixturePlan::from($plan->toString())->toString());
458 }
459
460 public function testSelectKeepsTheSubjectWhenABranchHasNoRelations(): void
461 {
462 $choice = \SqlFixture\Plan\RelationChoice::on('comments.kind')->when('post', Relation::manyToOne('comments.id', 'posts.id'))->when('none');
463 $plan = new FixturePlan($choice);
464 $selected = $plan->select($choice, $choice->cases[1]);
465 self::assertSame('comments', $selected->subjectTable());
466 self::assertSame([], $selected->relations);
467 self::assertSame([], $selected->choices);
468 self::assertSame([$choice], $plan->parts());
469 }
470
471 public function testWithChoiceKeepsExistingSubjectsAndOrdinaryRelations(): void
472 {
473 $plain = Relation::oneToMany('users.id', 'comments.user_id');
474 $choice = \SqlFixture\Plan\RelationChoice::on('comments.kind')->when('none');
475 $base = new FixturePlan('users', $plain, 'audit');
476 $plan = $base->withChoice($choice);
477 self::assertSame(['users', $plain, 'audit', $choice], $plan->parts());
478 self::assertSame('users', $plan->subjectTable());
479 }
480
481 public function testSelectRetainsPartsBeforeAndAfterTheChoice(): void
482 {
483 $choice = \SqlFixture\Plan\RelationChoice::on('comments.kind')->when('none');
484 $plan = new FixturePlan('users', $choice, 'audit');
485 self::assertSame(['users', 'comments', 'audit'], $plan->select($choice, $choice->cases[0])->parts());
486 }
487}
488