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