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)]
49final class FixturePlanTest extends TestCase
50{
51    #[Test]
52    public function testFromReadsTheRelationString(): void
53    {
54        $plan = FixturePlan::from('order.id < order_detail.order_id');
55
56        self::assertCount(1, $plan->relations);
57        self::assertSame(['order', 'order_detail'], $plan->tables);
58    }
59
60    #[Test]
61    public function testFromAcceptsAPlanAndCopiesIt(): void
62    {
63        $original = FixturePlan::from('order.id < order_detail.order_id');
64        $copy = FixturePlan::from($original);
65
66        self::assertNotSame($original, $copy);
67        self::assertSame($original->toString(), $copy->toString());
68    }
69
70    #[Test]
71    public function testToStringWritesThePlanBack(): void
72    {
73        $plan = FixturePlan::from('order.id<order_detail.order_id');
74
75        self::assertSame('order.id < order_detail.order_id', $plan->toString());
76    }
77
78    #[Test]
79    public function testCastingToStringWritesThePlanBack(): void
80    {
81        self::assertSame(
82            'order.id < order_detail.order_id',
83            (string) FixturePlan::from('order.id < order_detail.order_id')
84        );
85    }
86
87    #[Test]
88    public function testTableStartsAPlanWithNoRelations(): void
89    {
90        $plan = FixturePlan::table('order');
91
92        self::assertSame([], $plan->relations);
93        self::assertSame(['order'], $plan->tables);
94        self::assertSame('order', $plan->toString());
95    }
96
97    #[Test]
98    public function testWithOneToManyAddsARelation(): void
99    {
100        $plan = FixturePlan::table('order')->withOneToMany('order.id', 'order_detail.order_id');
101
102        self::assertSame('order.id < order_detail.order_id', $plan->toString());
103    }
104
105    #[Test]
106    public function testWithManyToOneAddsARelation(): void
107    {
108        $plan = FixturePlan::table('order')->withManyToOne('order.customer_id', 'customer.id');
109
110        self::assertSame('order.customer_id > customer.id', $plan->toString());
111    }
112
113    #[Test]
114    public function testWithManyToOneCanMarkTheParentOptional(): void
115    {
116        $plan = FixturePlan::table('order')->withManyToOne('order.customer_id', 'customer.id', true);
117
118        self::assertSame('order.customer_id >? customer.id', $plan->toString());
119        self::assertTrue($plan->relations[0]->parentIsOptional());
120    }
121
122    #[Test]
123    public function testWithOneToOneAddsARelation(): void
124    {
125        $plan = FixturePlan::table('order')->withOneToOne('order.id', 'order_shipping.order_id');
126
127        self::assertSame('order.id - order_shipping.order_id', $plan->toString());
128    }
129
130    #[Test]
131    public function testWithRelationAcceptsAColumnRefForCompositeKeys(): void
132    {
133        $plan = FixturePlan::table('order')->withOneToMany(
134            ColumnRef::of('order', 'shop_id', 'no'),
135            ColumnRef::of('order_detail', 'shop_id', 'order_no')
136        );
137
138        self::assertSame('order.(shop_id, no) < order_detail.(shop_id, order_no)', $plan->toString());
139    }
140
141    #[Test]
142    public function testWithTableNamesATableThatHasNoRelationYet(): void
143    {
144        $plan = FixturePlan::table('order')->withTable('audit_log');
145
146        self::assertSame(['order', 'audit_log'], $plan->tables);
147    }
148
149    #[Test]
150    public function testTablesAreNotRepeated(): void
151    {
152        $plan = FixturePlan::table('order')
153            ->withOneToMany('order.id', 'order_detail.order_id')
154            ->withOneToMany('order.id', 'shipment.order_id');
155
156        self::assertSame(['order', 'order_detail', 'shipment'], $plan->tables);
157    }
158
159    #[Test]
160    public function testEveryBuilderMethodReturnsANewInstance(): void
161    {
162        $base = FixturePlan::table('order');
163
164        self::assertNotSame($base, $base->withOneToMany('order.id', 'order_detail.order_id'));
165        self::assertNotSame($base, $base->withTable('customer'));
166        self::assertSame([], $base->relations);
167    }
168
169    #[Test]
170    public function testSubjectTableTheSubjectIsTheFirstTableNamed(): void
171    {
172        $plan = FixturePlan::from('order.id < order_detail.order_id, order.customer_id > customer.id');
173
174        self::assertSame('order', $plan->subjectTable());
175    }
176
177    #[Test]
178    public function testAnEmptyPlanHasNoSubject(): void
179    {
180        self::assertNull((new FixturePlan())->subjectTable());
181    }
182
183    #[Test]
184    public function testTheSubjectIsNotNecessarilyGeneratedFirst(): void
185    {
186        $plan = FixturePlan::from('order_detail.order_id > order.id');
187
188        self::assertSame('order_detail', $plan->subjectTable());
189        self::assertSame(['order', 'order_detail'], $plan->generationOrder);
190    }
191
192    #[Test]
193    public function testGenerationOrderPutsEveryParentBeforeItsChildren(): void
194    {
195        $plan = FixturePlan::from(
196            'order.id < order_detail.order_id, order_detail.product_id > product.id'
197        );
198
199        self::assertSame(['order', 'product', 'order_detail'], $plan->generationOrder);
200    }
201
202    #[Test]
203    public function testGenerationOrderCoversTablesThatStandAlone(): void
204    {
205        $plan = FixturePlan::from('order.id < order_detail.order_id, audit_log');
206
207        self::assertContains('audit_log', $plan->generationOrder);
208        self::assertCount(3, $plan->generationOrder);
209    }
210
211    #[Test]
212    public function testGenerationOrderHandlesSeveralIndependentComponents(): void
213    {
214        $plan = FixturePlan::from('a.id < b.a_id, c.id < d.c_id');
215
216        self::assertSame(['a', 'c', 'b', 'd'], $plan->generationOrder);
217    }
218
219    #[Test]
220    public function testRootsAreTheTablesNothingHasToPrecede(): void
221    {
222        $plan = FixturePlan::from('b.a_id > a.id, b.c_id > c.id');
223
224        self::assertSame(['a', 'c'], $plan->roots());
225    }
226
227    #[Test]
228    public function testDependenciesOfSelectsRelationsWhereTheTableIsTheChild(): void
229    {
230        $plan = FixturePlan::from(
231            'order.id < order_detail.order_id, order_detail.product_id > product.id'
232        );
233
234        self::assertCount(2, $plan->dependenciesOf('order_detail'));
235        self::assertSame([], $plan->dependenciesOf('order'));
236        self::assertSame([], $plan->dependenciesOf('product'));
237    }
238
239    #[Test]
240    public function testDependentsOfSelectsRelationsWhereTheTableIsTheParent(): void
241    {
242        $plan = FixturePlan::from(
243            'order.id < order_detail.order_id, order_detail.product_id > product.id'
244        );
245
246        self::assertCount(1, $plan->dependentsOf('order'));
247        self::assertSame('order_detail', $plan->dependentsOf('order')[0]->child()->table);
248        self::assertCount(1, $plan->dependentsOf('product'));
249        self::assertSame([], $plan->dependentsOf('order_detail'));
250    }
251
252    #[Test]
253    public function testAnOptionalSelfReferenceIsAllowed(): void
254    {
255        $plan = FixturePlan::from('category.id <? category.parent_id');
256
257        self::assertSame(['category'], $plan->generationOrder);
258    }
259
260    #[Test]
261    public function testTwoForeignKeysBetweenTheSameTablesAreAllowed(): void
262    {
263        $plan = FixturePlan::from('a.id < b.a_id, a.code < b.a_code');
264
265        self::assertCount(2, $plan->relations);
266        self::assertSame(['a', 'b'], $plan->generationOrder);
267    }
268
269    #[Test]
270    public function testTheStringAndTheObjectDescribeTheSamePlan(): void
271    {
272        $written = 'order.id < order_detail.order_id, order.customer_id > customer.id';
273        $built = FixturePlan::table('order')
274            ->withOneToMany('order.id', 'order_detail.order_id')
275            ->withManyToOne('order.customer_id', 'customer.id');
276
277        self::assertSame($written, $built->toString());
278        self::assertSame($written, FixturePlan::from($written)->toString());
279    }
280
281    #[Test]
282    public function testAPlanCanBeDeclaredAsAType(): void
283    {
284        $declaredPlan = new class () extends FixturePlan {
285            public function __construct()
286            {
287                parent::__construct(
288                    Relation::oneToMany('order.id', 'order_detail.order_id'),
289                    Relation::manyToOne('order.customer_id', 'customer.id'),
290                );
291            }
292        };
293        $plan = $declaredPlan;
294
295        self::assertSame(['order', 'order_detail', 'customer'], $plan->tables);
296    }
297
298    #[Test]
299    public function testADeclaredPlanWritesOutAsTheSameRelationString(): void
300    {
301        $declaredPlan = new class () extends FixturePlan {
302            public function __construct()
303            {
304                parent::__construct(
305                    Relation::oneToMany('order.id', 'order_detail.order_id'),
306                    Relation::manyToOne('order.customer_id', 'customer.id'),
307                );
308            }
309        };
310        self::assertSame(
311            'order.id < order_detail.order_id, order.customer_id > customer.id',
312            ($declaredPlan)->toString()
313        );
314    }
315
316    #[Test]
317    public function testADeclaredPlanEqualsTheParsedString(): void
318    {
319        $declaredPlan = new class () extends FixturePlan {
320            public function __construct()
321            {
322                parent::__construct(
323                    Relation::oneToMany('order.id', 'order_detail.order_id'),
324                    Relation::manyToOne('order.customer_id', 'customer.id'),
325                );
326            }
327        };
328        $declared = $declaredPlan;
329        $parsed = FixturePlan::from('order.id < order_detail.order_id, order.customer_id > customer.id');
330
331        self::assertSame($parsed->toString(), $declared->toString());
332        self::assertSame($parsed->tables, $declared->tables);
333        self::assertEquals($parsed->relations, $declared->relations);
334    }
335
336    #[Test]
337    public function testOfAcceptsRelationsWithoutAString(): void
338    {
339        $plan = new FixturePlan(
340            Relation::oneToMany('order.id', 'order_detail.order_id'),
341            Relation::manyToOne('order.customer_id', 'customer.id'),
342        );
343
344        self::assertSame(
345            'order.id < order_detail.order_id, order.customer_id > customer.id',
346            $plan->toString()
347        );
348    }
349
350    #[Test]
351    public function testAStringPartNamesAStandaloneTable(): void
352    {
353        $plan = new FixturePlan(Relation::oneToMany('order.id', 'order_detail.order_id'), 'audit_log');
354
355        self::assertSame(['order', 'order_detail', 'audit_log'], $plan->tables);
356    }
357
358    #[Test]
359    public function testAlteringADeclaredPlanGivesAPlainPlan(): void
360    {
361        $declaredPlan = new class () extends FixturePlan {
362            public function __construct()
363            {
364                parent::__construct(
365                    Relation::oneToMany('order.id', 'order_detail.order_id'),
366                    Relation::manyToOne('order.customer_id', 'customer.id'),
367                );
368            }
369        };
370        $plan = ($declaredPlan)->withTable('audit_log');
371
372        self::assertNotInstanceOf($declaredPlan::class, $plan);
373        self::assertSame(['order', 'order_detail', 'customer', 'audit_log'], $plan->tables);
374    }
375
376    #[Test]
377    public function testPartsSpreadFromAKeyedArrayAreStillReadInOrder(): void
378    {
379        $plan = new FixturePlan(...[
380            'first' => Relation::oneToMany('a.id', 'b.a_id'),
381            'second' => 'audit_log',
382        ]);
383
384        self::assertSame(['a', 'b', 'audit_log'], $plan->tables);
385        self::assertSame('a.id < b.a_id, audit_log', $plan->toString());
386    }
387
388    #[Test]
389    public function testATableNameIsTakenWithoutSurroundingSpace(): void
390    {
391        self::assertSame(['order'], (new FixturePlan('  order  '))->tables);
392    }
393
394    #[Test]
395    public function testAQuotedTableNameLosesItsQuotes(): void
396    {
397        self::assertSame(['order'], (new FixturePlan('`order`'))->tables);
398        self::assertSame(['order'], (new FixturePlan('"order"'))->tables);
399    }
400
401    #[Test]
402    public function testDependenciesOfReturnsAListEvenWhenTheMatchIsNotFirst(): void
403    {
404        $plan = FixturePlan::from('a.id < b.a_id, c.id < d.c_id');
405
406        self::assertSame([$plan->relations[1]], $plan->dependenciesOf('d'));
407    }
408
409    #[Test]
410    public function testDependentsOfReturnsAListEvenWhenTheMatchIsNotFirst(): void
411    {
412        $plan = FixturePlan::from('a.id < b.a_id, c.id < d.c_id');
413
414        self::assertSame([$plan->relations[1]], $plan->dependentsOf('c'));
415    }
416
417    #[Test]
418    public function testRootsReturnsAListEvenWhenTheFirstTableIsNotOne(): void
419    {
420        $plan = FixturePlan::from('b.a_id > a.id');
421
422        self::assertSame(['a'], $plan->roots());
423    }
424
425    #[Test]
426    public function testWithRelationAddsARelationDirectly(): void
427    {
428        $plan = FixturePlan::table('order')->withRelation(Relation::oneToMany('order.id', 'order_detail.order_id'));
429
430        self::assertSame('order.id < order_detail.order_id', $plan->toString());
431    }
432
433    #[Test]
434    public function testAPlanBuiltFromAKeyedSpreadCanStillBeExtended(): void
435    {
436        $plan = (new FixturePlan(...['first' => Relation::oneToMany('a.id', 'b.a_id')]))->withTable('audit_log');
437
438        self::assertSame(['a', 'b', 'audit_log'], $plan->tables);
439    }
440}
441