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