packages/sql-fixture/tests/Unit/Fixture/PlanGeneratorTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Fixture;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\Test;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlFixture\Fixture\FixtureSet;
12use SqlFixture\Fixture\GenerationRun;
13use SqlFixture\Fixture\PlanGenerator;
14use SqlFixture\Fixture\PlanSchemaException;
15use SqlFixture\Fixture\PlanSchemaValidator;
16use SqlFixture\Fixture\RowSpec;
17use SqlFixture\Fixture\TableOverrides;
18use SqlFixture\Plan\ColumnRef;
19use SqlFixture\Plan\FixturePlan;
20use SqlFixture\Plan\PlanParser;
21use SqlFixture\Plan\PlanPrinter;
22use SqlFixture\Plan\Relation;
23use SqlFixture\Plan\RelationKind;
24use SqlFixture\Plan\RelationSide;
25use SqlFixture\Platform\MySql\MySqlSchemaParser;
26use SqlFixture\Platform\MySql\MySqlTypeMapper;
27use SqlFixture\Provider\FixtureGenerator;
28use SqlFixture\Schema\ColumnDefinition;
29use SqlFixture\Schema\SchemaNotFoundException;
30use SqlFixture\Schema\StaticSchemaResolver;
31use SqlFixture\Schema\TableSchema;
32
33#[CoversClass(PlanGenerator::class)]
34#[UsesClass(GenerationRun::class)]
35#[CoversClass(PlanSchemaValidator::class)]
36#[UsesClass(PlanSchemaException::class)]
37#[UsesClass(RowSpec::class)]
38#[UsesClass(FixtureSet::class)]
39#[UsesClass(TableOverrides::class)]
40#[UsesClass(FixtureGenerator::class)]
41#[CoversClass(FixturePlan::class)]
42#[CoversClass(PlanParser::class)]
43#[CoversClass(PlanPrinter::class)]
44#[UsesClass(Relation::class)]
45#[UsesClass(ColumnRef::class)]
46#[UsesClass(RelationKind::class)]
47#[UsesClass(RelationSide::class)]
48#[UsesClass(MySqlSchemaParser::class)]
49#[UsesClass(MySqlTypeMapper::class)]
50#[UsesClass(StaticSchemaResolver::class)]
51#[UsesClass(SchemaNotFoundException::class)]
52#[UsesClass(TableSchema::class)]
53#[UsesClass(ColumnDefinition::class)]
54#[CoversClass(\SqlFixture\Fixture\Generation\ConnectedTables::class)]
55#[CoversClass(\SqlFixture\Fixture\Generation\OverrideSpecs::class)]
56#[CoversClass(\SqlFixture\Fixture\Generation\RelationCounts::class)]
57#[CoversClass(\SqlFixture\Fixture\Generation\RelationProjection::class)]
58#[CoversClass(\SqlFixture\Fixture\Generation\RowMaterializer::class)]
59#[UsesClass(\SqlFixture\Fixture\OverrideRows::class)]
60#[UsesClass(\SqlFixture\Fixture\RowGeneration::class)]
61#[UsesClass(\SqlFixture\Hydrator\HydrationException::class)]
62#[UsesClass(\SqlFixture\Hydrator\HydratorInterface::class)]
63#[UsesClass(\SqlFixture\Hydrator\ReflectionHydrator::class)]
64#[UsesClass(\SqlFixture\Plan\PlanSyntaxException::class)]
65#[UsesClass(\SqlFixture\Schema\SchemaParseException::class)]
66#[UsesClass(\SqlFixture\Schema\SchemaParserInterface::class)]
67#[UsesClass(\SqlFixture\Schema\SchemaResolverInterface::class)]
68#[UsesClass(\SqlFixture\Schema\TableIdentifier::class)]
69#[UsesClass(\SqlFixture\TypeMapper\TypeMapperInterface::class)]
70#[UsesClass(\SqlFixture\Fixture\Validation\EndpointValidator::class)]
71#[UsesClass(\SqlFixture\Fixture\Validation\OverrideValidator::class)]
72#[UsesClass(\SqlFixture\Hydrator\Reflection\ConstructorHydration::class)]
73#[UsesClass(\SqlFixture\Hydrator\Reflection\PropertyHydration::class)]
74#[UsesClass(\SqlFixture\Hydrator\Reflection\PropertyNames::class)]
75#[UsesClass(\SqlFixture\Hydrator\Reflection\ValueConversion::class)]
76#[UsesClass(\SqlFixture\Fixture\Exception\InvalidOverrideException::class)]
77#[UsesClass(\SqlFixture\Plan\Parsing\PlanStatements::class)]
78#[UsesClass(\SqlFixture\Plan\Parsing\RelationCursor::class)]
79#[UsesClass(\SqlFixture\Plan\Parsing\RelationReader::class)]
80#[UsesClass(\SqlFixture\Plan\PlanStructureException::class)]
81#[UsesClass(\SqlFixture\Plan\Printing\PlanTables::class)]
82#[UsesClass(\SqlFixture\Plan\Printing\RelationGroups::class)]
83#[UsesClass(\SqlFixture\Plan\Printing\StatementPrinter::class)]
84#[UsesClass(\SqlFixture\Plan\Validation\PlanValidation::class)]
85#[UsesClass(\SqlFixture\Plan\Validation\TableName::class)]
86#[UsesClass(\SqlFixture\Platform\MySql\Schema\ColumnParser::class)]
87#[UsesClass(\SqlFixture\Platform\MySql\Schema\DefaultExpression::class)]
88#[UsesClass(\SqlFixture\Platform\MySql\Schema\DefinitionIntegrity::class)]
89#[UsesClass(\SqlFixture\Platform\MySql\Schema\TableDefinition::class)]
90#[UsesClass(\SqlFixture\Platform\MySql\Schema\TypeParameters::class)]
91#[UsesClass(\SqlFixture\Platform\MySql\Value\ColumnGenerator::class)]
92#[UsesClass(\SqlFixture\Platform\MySql\Value\DecimalGenerator::class)]
93#[UsesClass(\SqlFixture\Platform\MySql\Value\GeometryGenerator::class)]
94#[UsesClass(\SqlFixture\Platform\MySql\Value\IntegerGenerator::class)]
95#[UsesClass(\SqlFixture\Platform\MySql\Value\NumericGenerator::class)]
96#[UsesClass(\SqlFixture\Platform\MySql\Value\SpatialGenerator::class)]
97#[UsesClass(\SqlFixture\Platform\MySql\Value\StringGenerator::class)]
98#[UsesClass(\SqlFixture\Platform\MySql\Value\TextGenerator::class)]
99#[UsesClass(\SqlFixture\Schema\TypeShape::class)]
100#[UsesClass(\SqlFixture\TypeMapper\ParagraphGenerator::class)]
101#[\PHPUnit\Framework\Attributes\Medium]
102#[UsesClass(\SqlFixture\Platform\MySql\Schema\TableDefinitionInput::class)]
103#[UsesClass(\SqlFixture\Hydrator\Exception\ClassNotFoundException::class)]
104#[UsesClass(\SqlFixture\Hydrator\Exception\MissingConstructorArgumentException::class)]
105#[UsesClass(\SqlFixture\Fixture\Exception\UnknownOverrideColumnException::class)]
106#[UsesClass(\SqlFixture\Fixture\Exception\NullOverrideException::class)]
107#[UsesClass(\SqlFixture\Fixture\Exception\GeneratedColumnOverrideException::class)]
108#[UsesClass(\SqlFixture\Fixture\Exception\GeneratedColumnReferenceException::class)]
109#[UsesClass(\SqlFixture\Fixture\Exception\MissingRelationValueException::class)]
110#[UsesClass(\SqlFixture\Fixture\Exception\UnknownPlanColumnException::class)]
111#[UsesClass(\SqlFixture\Plan\Exception\EmptyPlanException::class)]
112#[UsesClass(\SqlFixture\Plan\Exception\EmptyTableNameException::class)]
113#[UsesClass(\SqlFixture\Plan\Exception\MissingEndpointColumnsException::class)]
114#[UsesClass(\SqlFixture\Plan\Exception\InvalidTableNameException::class)]
115#[UsesClass(\SqlFixture\Plan\Exception\UnbalancedBracketsException::class)]
116#[UsesClass(\SqlFixture\Plan\Exception\UnexpectedPlanTokenException::class)]
117#[UsesClass(\SqlFixture\Plan\Exception\UnsupportedManyToManyException::class)]
118#[UsesClass(\SqlFixture\Plan\Exception\CompositeArityMismatchException::class)]
119#[UsesClass(\SqlFixture\Plan\Exception\DuplicateColumnBindingException::class)]
120#[UsesClass(\SqlFixture\Plan\Exception\CyclicDependencyException::class)]
121#[UsesClass(\SqlFixture\Plan\Exception\UnboundedSelfReferenceException::class)]
122#[UsesClass(\SqlFixture\Schema\Exception\InvalidSqlException::class)]
123#[UsesClass(\SqlFixture\Schema\Exception\ExpectedCreateTableException::class)]
124#[UsesClass(\SqlFixture\Schema\Exception\MissingColumnDefinitionsException::class)]
125#[UsesClass(\SqlFixture\TypeMapper\IntegerRange::class)]
126#[UsesClass(\SqlFixture\TypeMapper\IntegerWidth::class)]
127#[UsesClass(\SqlFixture\TypeMapper\DecimalRange::class)]
128#[UsesClass(\SqlFixture\Hydrator\Reflection\ConversionTarget::class)]
129#[CoversClass(\SqlFixture\Fixture\Choice\CaseSelection::class)]
130#[CoversClass(\SqlFixture\Fixture\Choice\ChoiceBindings::class)]
131#[CoversClass(\SqlFixture\Fixture\Choice\InverseRelations::class)]
132#[CoversClass(\SqlFixture\Fixture\Choice\ResolvedRow::class)]
133#[CoversClass(\SqlFixture\Fixture\Choice\RowChoices::class)]
134#[UsesClass(\SqlFixture\Plan\Choice\ChoiceCase::class)]
135#[UsesClass(\SqlFixture\Plan\Choice\ChoiceDefinitionException::class)]
136#[UsesClass(\SqlFixture\Plan\Choice\ChoiceLiteral::class)]
137#[UsesClass(\SqlFixture\Plan\Choice\ChoiceSyntax::class)]
138#[UsesClass(\SqlFixture\Plan\Choice\ChoiceValidation::class)]
139#[UsesClass(\SqlFixture\Plan\Choice\PlanContents::class)]
140#[UsesClass(\SqlFixture\Plan\RelationChoice::class)]
141#[CoversClass(\SqlFixture\Fixture\Generation\RowBindings::class)]
142#[UsesClass(\SqlFixture\Fixture\Generation\RelationValueException::class)]
143#[UsesClass(\SqlFixture\Fixture\Generation\RecursiveRelationException::class)]
144#[UsesClass(\SqlFixture\Fixture\RowGenerator::class)]
145final class PlanGeneratorTest extends TestCase
146{
147    #[Test]
148    public function testGenerateTheTableThePlanIsAboutGetsOneRow(): void
149    {
150        $schemas = new StaticSchemaResolver([
151            new TableSchema('order', [
152                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
153                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
154                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
155                'total' => new ColumnDefinition('total', 'INT', nullable: false),
156            ], ['id']),
157            new TableSchema('order_detail', [
158                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
159                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
160                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
161                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
162            ], ['id']),
163        ]);
164        $faker = \Faker\Factory::create();
165        $faker->seed(20260101);
166        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
167        $set = $generator->generate(
168            FixturePlan::from('order.id < order_detail.order_id')
169        );
170
171        self::assertArrayHasKey('id', (array) $set->row('order'));
172    }
173
174    #[Test]
175    public function testTheSubjectIsOneRowEvenWhenSomethingReferencesIt(): void
176    {
177        $schemas = new StaticSchemaResolver([
178            new TableSchema('customer', [
179                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
180                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
181                'tier' => new ColumnDefinition('tier', 'ENUM', nullable: false, enumValues: ['gold', 'silver']),
182            ], ['id']),
183            new TableSchema('order', [
184                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
185                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
186                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
187                'total' => new ColumnDefinition('total', 'INT', nullable: false),
188            ], ['id']),
189            new TableSchema('order_detail', [
190                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
191                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
192                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
193                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
194            ], ['id']),
195        ]);
196        $faker = \Faker\Factory::create();
197        $faker->seed(20260101);
198        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
199        $set = $generator->generate(
200            FixturePlan::from('order.id < order_detail.order_id, order.customer_id > customer.id')
201        );
202
203        self::assertArrayHasKey('id', (array) $set->row('order'));
204        self::assertArrayHasKey('id', (array) $set->row('customer'));
205    }
206
207    #[Test]
208    public function testChildRowsCarryTheParentKey(): void
209    {
210        $schemas = new StaticSchemaResolver([
211            new TableSchema('order', [
212                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
213                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
214                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
215                'total' => new ColumnDefinition('total', 'INT', nullable: false),
216            ], ['id']),
217            new TableSchema('order_detail', [
218                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
219                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
220                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
221                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
222            ], ['id']),
223        ]);
224        $faker = \Faker\Factory::create();
225        $faker->seed(20260101);
226        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
227        $set = $generator->generate(
228            FixturePlan::from('order.id < order_detail.order_id'),
229            ['order' => ['id' => 100], 'order_detail' => 3]
230        );
231
232        self::assertCount(3, $set->rows('order_detail'));
233        self::assertSame([100, 100, 100], array_column($set->rows('order_detail'), 'order_id'));
234    }
235
236    #[Test]
237    public function testAParentIsGeneratedAndLinkedTo(): void
238    {
239        $schemas = new StaticSchemaResolver([
240            new TableSchema('customer', [
241                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
242                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
243                'tier' => new ColumnDefinition('tier', 'ENUM', nullable: false, enumValues: ['gold', 'silver']),
244            ], ['id']),
245            new TableSchema('order', [
246                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
247                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
248                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
249                'total' => new ColumnDefinition('total', 'INT', nullable: false),
250            ], ['id']),
251        ]);
252        $faker = \Faker\Factory::create();
253        $faker->seed(20260101);
254        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
255        $set = $generator->generate(
256            FixturePlan::from('order.customer_id > customer.id')
257        );
258
259        self::assertSame($set->rows('customer')[0]['id'], $set->rows('order')[0]['customer_id']);
260    }
261
262    #[Test]
263    public function testACountOfZeroGeneratesNoRows(): void
264    {
265        $schemas = new StaticSchemaResolver([
266            new TableSchema('order', [
267                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
268                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
269                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
270                'total' => new ColumnDefinition('total', 'INT', nullable: false),
271            ], ['id']),
272            new TableSchema('order_detail', [
273                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
274                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
275                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
276                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
277            ], ['id']),
278        ]);
279        $faker = \Faker\Factory::create();
280        $faker->seed(20260101);
281        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
282        $set = $generator->generate(
283            FixturePlan::from('order.id < order_detail.order_id'),
284            ['order_detail' => []]
285        );
286
287        self::assertSame([], $set->rows('order_detail'));
288    }
289
290    #[Test]
291    public function testOneSetOfValuesAppliesToEveryGeneratedRow(): void
292    {
293        $schemas = new StaticSchemaResolver([
294            new TableSchema('order', [
295                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
296                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
297                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
298                'total' => new ColumnDefinition('total', 'INT', nullable: false),
299            ], ['id']),
300            new TableSchema('order_detail', [
301                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
302                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
303                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
304                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
305            ], ['id']),
306        ]);
307        $faker = \Faker\Factory::create();
308        $faker->seed(20260101);
309        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
310        $set = $generator->generate(
311            FixturePlan::from('order.id < order_detail.order_id'),
312            ['order_detail' => ['quantity' => 2]]
313        );
314
315        self::assertNotSame([], $set->rows('order_detail'));
316        $quantities = array_column($set->rows('order_detail'), 'quantity');
317
318        self::assertSame([2], array_values(array_unique($quantities, SORT_REGULAR)));
319    }
320
321    #[Test]
322    public function testAListGivesOneRowPerEntry(): void
323    {
324        $schemas = new StaticSchemaResolver([
325            new TableSchema('order', [
326                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
327                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
328                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
329                'total' => new ColumnDefinition('total', 'INT', nullable: false),
330            ], ['id']),
331            new TableSchema('order_detail', [
332                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
333                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
334                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
335                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
336            ], ['id']),
337        ]);
338        $faker = \Faker\Factory::create();
339        $faker->seed(20260101);
340        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
341        $set = $generator->generate(
342            FixturePlan::from('order.id < order_detail.order_id'),
343            ['order_detail' => [['quantity' => 1], ['quantity' => 2]]]
344        );
345
346        self::assertSame([1, 2], array_column($set->rows('order_detail'), 'quantity'));
347    }
348
349    #[Test]
350    public function testAnUnmentionedChildIsStillGenerated(): void
351    {
352        $schemas = new StaticSchemaResolver([
353            new TableSchema('order', [
354                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
355                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
356                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
357                'total' => new ColumnDefinition('total', 'INT', nullable: false),
358            ], ['id']),
359            new TableSchema('order_detail', [
360                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
361                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
362                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
363                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
364            ], ['id']),
365        ]);
366        $faker = \Faker\Factory::create();
367        $faker->seed(20260101);
368        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
369        $set = $generator->generate(
370            FixturePlan::from('order.id < order_detail.order_id')
371        );
372
373        self::assertGreaterThanOrEqual(1, count($set->rows('order_detail')));
374    }
375
376    #[Test]
377    public function testAnOptionalChildMayGenerateNoneAtAll(): void
378    {
379        $schemas = new StaticSchemaResolver([
380            new TableSchema('order', [
381                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
382                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
383                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
384                'total' => new ColumnDefinition('total', 'INT', nullable: false),
385            ], ['id']),
386            new TableSchema('order_detail', [
387                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
388                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
389                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
390                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
391            ], ['id']),
392        ]);
393        $faker = \Faker\Factory::create();
394        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
395        $counts = array_map(static function (int $seed) use ($faker, $generator): int {
396            $faker->seed($seed);
397            return count($generator->generate(FixturePlan::from('order.id <? order_detail.order_id'))->rows('order_detail'));
398        }, range(1, 40));
399
400        self::assertContains(0, $counts);
401    }
402
403    #[Test]
404    #[\PHPUnit\Framework\Attributes\DataProvider('providerGenerationSeeds')]
405    public function testARequiredChildNeverGeneratesNone(int $seed): void
406    {
407        $schemas = new StaticSchemaResolver([
408            new TableSchema('order', [
409                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
410                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
411                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
412                'total' => new ColumnDefinition('total', 'INT', nullable: false),
413            ], ['id']),
414            new TableSchema('order_detail', [
415                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
416                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
417                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
418                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
419            ], ['id']),
420        ]);
421        $faker = \Faker\Factory::create();
422        $faker->seed($seed);
423        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
424        $set = $generator->generate(FixturePlan::from('order.id < order_detail.order_id'));
425
426        self::assertGreaterThanOrEqual(1, count($set->rows('order_detail')));
427    }
428
429    #[Test]
430    public function testAOneToOneChildIsASingleRow(): void
431    {
432        $schemas = new StaticSchemaResolver([
433            new TableSchema('order', [
434                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
435                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
436                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
437                'total' => new ColumnDefinition('total', 'INT', nullable: false),
438            ], ['id']),
439            new TableSchema('order_shipping', [
440                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
441                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
442                'carrier' => new ColumnDefinition('carrier', 'VARCHAR', nullable: false, length: 50),
443            ], ['id']),
444        ]);
445        $faker = \Faker\Factory::create();
446        $faker->seed(20260101);
447        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
448        $set = $generator->generate(
449            FixturePlan::from('order.id - order_shipping.order_id'),
450            ['order' => ['id' => 5]]
451        );
452
453        self::assertSame(5, $set->rows('order_shipping')[0]['order_id']);
454    }
455
456    #[Test]
457    public function testAnOptionalOneToOneAskedForNoneIsNull(): void
458    {
459        $schemas = new StaticSchemaResolver([
460            new TableSchema('order', [
461                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
462                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
463                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
464                'total' => new ColumnDefinition('total', 'INT', nullable: false),
465            ], ['id']),
466            new TableSchema('order_shipping', [
467                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
468                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
469                'carrier' => new ColumnDefinition('carrier', 'VARCHAR', nullable: false, length: 50),
470            ], ['id']),
471        ]);
472        $faker = \Faker\Factory::create();
473        $faker->seed(20260101);
474        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
475        $set = $generator->generate(
476            FixturePlan::from('order.id -? order_shipping.order_id'),
477            ['order_shipping' => []]
478        );
479
480        self::assertNull($set->row('order_shipping'));
481    }
482
483    #[Test]
484    public function testEveryChildOfAListGetsItsOwnParent(): void
485    {
486        $schemas = new StaticSchemaResolver([
487            new TableSchema('order', [
488                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
489                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
490                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
491                'total' => new ColumnDefinition('total', 'INT', nullable: false),
492            ], ['id']),
493            new TableSchema('order_detail', [
494                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
495                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
496                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
497                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
498            ], ['id']),
499            new TableSchema('product', [
500                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
501                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
502            ], ['id']),
503        ]);
504        $faker = \Faker\Factory::create();
505        $faker->seed(20260101);
506        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
507        $set = $generator->generate(
508            FixturePlan::from('order.id < order_detail.order_id, order_detail.product_id > product.id'),
509            ['order_detail' => 3]
510        );
511
512        self::assertCount(3, $set->rows('product'));
513        self::assertSame(
514            array_column($set->rows('product'), 'id'),
515            array_column($set->rows('order_detail'), 'product_id')
516        );
517    }
518
519    #[Test]
520    public function testFixingTheLinkingColumnLeavesTheParentAlone(): void
521    {
522        $schemas = new StaticSchemaResolver([
523            new TableSchema('customer', [
524                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
525                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
526                'tier' => new ColumnDefinition('tier', 'ENUM', nullable: false, enumValues: ['gold', 'silver']),
527            ], ['id']),
528            new TableSchema('order', [
529                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
530                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
531                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
532                'total' => new ColumnDefinition('total', 'INT', nullable: false),
533            ], ['id']),
534        ]);
535        $faker = \Faker\Factory::create();
536        $faker->seed(20260101);
537        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
538        $set = $generator->generate(
539            FixturePlan::from('order.customer_id > customer.id'),
540            ['order' => ['customer_id' => 77]]
541        );
542
543        self::assertSame(77, $set->rows('order')[0]['customer_id']);
544        self::assertNull($set->row('customer'));
545    }
546
547    #[Test]
548    public function testTablesThatStandAloneAreGeneratedToo(): void
549    {
550        $schemas = new StaticSchemaResolver([
551            new TableSchema('order', [
552                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
553                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
554                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
555                'total' => new ColumnDefinition('total', 'INT', nullable: false),
556            ], ['id']),
557            new TableSchema('order_detail', [
558                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
559                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
560                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
561                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
562            ], ['id']),
563            new TableSchema('audit_log', [
564                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
565                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
566                'message' => new ColumnDefinition('message', 'VARCHAR', nullable: false, length: 255),
567            ], ['id']),
568        ]);
569        $faker = \Faker\Factory::create();
570        $faker->seed(20260101);
571        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
572        $set = $generator->generate(
573            FixturePlan::from('order.id < order_detail.order_id, audit_log')
574        );
575
576        self::assertArrayHasKey('message', (array) $set->row('audit_log'));
577    }
578
579    #[Test]
580    public function testEntriesComeBackInTheOrderThePlanNamesThem(): void
581    {
582        $schemas = new StaticSchemaResolver([
583            new TableSchema('customer', [
584                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
585                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
586                'tier' => new ColumnDefinition('tier', 'ENUM', nullable: false, enumValues: ['gold', 'silver']),
587            ], ['id']),
588            new TableSchema('order', [
589                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
590                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
591                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
592                'total' => new ColumnDefinition('total', 'INT', nullable: false),
593            ], ['id']),
594            new TableSchema('order_detail', [
595                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
596                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
597                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
598                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
599            ], ['id']),
600        ]);
601        $faker = \Faker\Factory::create();
602        $faker->seed(20260101);
603        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
604        $set = $generator->generate(
605            FixturePlan::from('order.id < order_detail.order_id, order.customer_id > customer.id'),
606            ['order' => ['id' => 1], 'order_detail' => 2]
607        );
608
609        [$order, $details, $customer] = $set;
610
611        self::assertSame(1, ((array) $order)['id']);
612        self::assertCount(2, (array) $details);
613        self::assertArrayHasKey('tier', (array) $customer);
614    }
615
616    #[Test]
617    public function testOverridesMayBeGivenAsTableOverrides(): void
618    {
619        $schemas = new StaticSchemaResolver([
620            new TableSchema('order', [
621                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
622                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
623                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
624                'total' => new ColumnDefinition('total', 'INT', nullable: false),
625            ], ['id']),
626            new TableSchema('order_detail', [
627                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
628                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
629                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
630                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
631            ], ['id']),
632        ]);
633        $faker = \Faker\Factory::create();
634        $faker->seed(20260101);
635        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
636        $set = $generator->generate(
637            FixturePlan::from('order.id < order_detail.order_id'),
638            ['order' => TableOverrides::of(['status' => 'paid'])]
639        );
640
641        self::assertSame('paid', $set->rows('order')[0]['status']);
642    }
643
644    #[Test]
645    public function testAnUnknownTableIsReported(): void
646    {
647        $schemas = new StaticSchemaResolver([
648        ]);
649        $faker = \Faker\Factory::create();
650        $faker->seed(20260101);
651        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
652        $this->expectException(SchemaNotFoundException::class);
653        $this->expectExceptionMessage('Schema not found for table: nope');
654
655        $generator->generate(FixturePlan::from('nope'));
656    }
657
658    #[Test]
659    public function testAPlanNamingAColumnTheTableLacksIsRejectedBeforeGenerating(): void
660    {
661        $schemas = new StaticSchemaResolver([
662            new TableSchema('order', [
663                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
664                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
665                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
666                'total' => new ColumnDefinition('total', 'INT', nullable: false),
667            ], ['id']),
668            new TableSchema('order_detail', [
669                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
670                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
671                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
672                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
673            ], ['id']),
674        ]);
675        $faker = \Faker\Factory::create();
676        $faker->seed(20260101);
677        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
678        $this->expectException(\SqlFixture\Fixture\Exception\UnknownPlanColumnException::class);
679        $this->expectExceptionMessage('order_detail has no column oder_id');
680
681        $generator->generate(FixturePlan::from('order.id < order_detail.oder_id'));
682    }
683
684    #[Test]
685    public function testTheSubjectHonoursACountItWasGiven(): void
686    {
687        $schemas = new StaticSchemaResolver([
688            new TableSchema('order', [
689                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
690                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
691                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
692                'total' => new ColumnDefinition('total', 'INT', nullable: false),
693            ], ['id']),
694            new TableSchema('order_detail', [
695                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
696                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
697                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
698                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
699            ], ['id']),
700        ]);
701        $faker = \Faker\Factory::create();
702        $faker->seed(20260101);
703        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
704        $set = $generator->generate(
705            FixturePlan::from('order.id < order_detail.order_id'),
706            ['order' => 2, 'order_detail' => 1]
707        );
708
709        self::assertCount(2, $set->rows('order'));
710    }
711
712    #[Test]
713    public function testARowCarriesBothItsInheritedKeyAndItsOwnParentKey(): void
714    {
715        $schemas = new StaticSchemaResolver([
716            new TableSchema('order', [
717                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
718                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
719                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
720                'total' => new ColumnDefinition('total', 'INT', nullable: false),
721            ], ['id']),
722            new TableSchema('order_detail', [
723                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
724                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
725                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
726                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
727            ], ['id']),
728            new TableSchema('product', [
729                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
730                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
731            ], ['id']),
732        ]);
733        $faker = \Faker\Factory::create();
734        $faker->seed(20260101);
735        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
736        $set = $generator->generate(
737            FixturePlan::from('order.id < order_detail.order_id, order_detail.product_id > product.id'),
738            ['order' => ['id' => 42], 'order_detail' => 1]
739        );
740
741        $detail = $set->rows('order_detail')[0];
742
743        self::assertSame(42, $detail['order_id']);
744        self::assertSame($set->rows('product')[0]['id'], $detail['product_id']);
745    }
746
747    #[Test]
748    public function testEveryOtherRelationOfAParentIsStillFollowed(): void
749    {
750        $schemas = new StaticSchemaResolver([
751            new TableSchema('customer', [
752                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
753                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
754                'tier' => new ColumnDefinition('tier', 'ENUM', nullable: false, enumValues: ['gold', 'silver']),
755            ], ['id']),
756            new TableSchema('order', [
757                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
758                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
759                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
760                'total' => new ColumnDefinition('total', 'INT', nullable: false),
761            ], ['id']),
762            new TableSchema('audit_log', [
763                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
764                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
765                'message' => new ColumnDefinition('message', 'VARCHAR', nullable: false, length: 255),
766            ], ['id']),
767        ]);
768        $faker = \Faker\Factory::create();
769        $faker->seed(20260101);
770        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
771        $set = $generator->generate(
772            FixturePlan::from('order.customer_id > customer.id, customer.id < audit_log.customer_id')
773        );
774
775        self::assertNotSame([], $set->rows('audit_log'));
776    }
777
778    #[Test]
779    public function testAnOptionalParentIsGeneratedWhenTheCallerAsksForIt(): void
780    {
781        $schemas = new StaticSchemaResolver([
782            new TableSchema('customer', [
783                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
784                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
785                'tier' => new ColumnDefinition('tier', 'ENUM', nullable: false, enumValues: ['gold', 'silver']),
786            ], ['id']),
787            new TableSchema('order', [
788                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
789                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
790                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
791                'total' => new ColumnDefinition('total', 'INT', nullable: false),
792            ], ['id']),
793        ]);
794        $faker = \Faker\Factory::create();
795        $faker->seed(20260101);
796        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
797        $set = $generator->generate(
798            FixturePlan::from('order.customer_id >? customer.id'),
799            ['customer' => ['tier' => 'gold']]
800        );
801
802        self::assertSame('gold', $set->rows('customer')[0]['tier']);
803        self::assertSame($set->rows('customer')[0]['id'], $set->rows('order')[0]['customer_id']);
804    }
805
806    #[Test]
807    public function testACompositeRelationCarriesEveryColumn(): void
808    {
809        $schemas = new StaticSchemaResolver([
810            new TableSchema('shop_order', [
811                'shop_id' => new ColumnDefinition('shop_id', 'INT', nullable: false, unsigned: true),
812                'no' => new ColumnDefinition('no', 'INT', nullable: false, unsigned: true),
813            ], ['shop_id', 'no']),
814            new TableSchema('shop_order_line', [
815                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
816                'shop_id' => new ColumnDefinition('shop_id', 'INT', nullable: false, unsigned: true),
817                'order_no' => new ColumnDefinition('order_no', 'INT', nullable: false, unsigned: true),
818            ], ['id']),
819        ]);
820        $faker = \Faker\Factory::create();
821        $faker->seed(20260101);
822        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
823        $set = $generator->generate(
824            FixturePlan::from('shop_order.(shop_id, no) < shop_order_line.(shop_id, order_no)'),
825            ['shop_order' => ['shop_id' => 7, 'no' => 9], 'shop_order_line' => 1]
826        );
827
828        $line = $set->rows('shop_order_line')[0];
829
830        self::assertSame(7, $line['shop_id']);
831        self::assertSame(9, $line['order_no']);
832    }
833
834    #[Test]
835    public function testAOneToOneChildIsGeneratedExactlyOnce(): void
836    {
837        $schemas = new StaticSchemaResolver([
838            new TableSchema('order', [
839                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
840                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
841                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
842                'total' => new ColumnDefinition('total', 'INT', nullable: false),
843            ], ['id']),
844            new TableSchema('order_shipping', [
845                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
846                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
847                'carrier' => new ColumnDefinition('carrier', 'VARCHAR', nullable: false, length: 50),
848            ], ['id']),
849        ]);
850        $faker = \Faker\Factory::create();
851        $faker->seed(20260101);
852        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
853        $set = $generator->generate(
854            FixturePlan::from('order.id - order_shipping.order_id')
855        );
856
857        self::assertCount(1, $set->rows('order_shipping'));
858    }
859
860    #[Test]
861    public function testKeysAreStoodInForEveryColumnARelationReads(): void
862    {
863        $schemas = new StaticSchemaResolver([
864            new TableSchema('twin', [
865                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
866                'other_id' => new ColumnDefinition('other_id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
867            ], ['id']),
868            new TableSchema('twin_child', [
869                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
870                'twin_id' => new ColumnDefinition('twin_id', 'INT', nullable: false, unsigned: true),
871            ], ['id']),
872            new TableSchema('twin_other', [
873                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
874                'twin_other_id' => new ColumnDefinition('twin_other_id', 'INT', nullable: false, unsigned: true),
875            ], ['id']),
876        ]);
877        $faker = \Faker\Factory::create();
878        $faker->seed(20260101);
879        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
880        $set = $generator->generate(
881            FixturePlan::from('twin.id < twin_child.twin_id, twin.other_id < twin_other.twin_other_id')
882        );
883
884        self::assertSame(1, $set->rows('twin')[0]['id']);
885        self::assertSame(1, $set->rows('twin')[0]['other_id']);
886    }
887
888    #[Test]
889    public function testARequiredParentIsGeneratedWhetherOrNotItWasAskedFor(): void
890    {
891        $schemas = new StaticSchemaResolver([
892            new TableSchema('customer', [
893                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
894                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
895                'tier' => new ColumnDefinition('tier', 'ENUM', nullable: false, enumValues: ['gold', 'silver']),
896            ], ['id']),
897            new TableSchema('order', [
898                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
899                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
900                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
901                'total' => new ColumnDefinition('total', 'INT', nullable: false),
902            ], ['id']),
903        ]);
904        $faker = \Faker\Factory::create();
905        $faker->seed(20260101);
906        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
907        $set = $generator->generate(FixturePlan::from('order.customer_id > customer.id'));
908
909        self::assertNotSame([], $set->rows('customer'));
910    }
911
912    #[Test]
913    public function testAnOptionalParentNobodyAskedForIsLeftOut(): void
914    {
915        $schemas = new StaticSchemaResolver([
916            new TableSchema('customer', [
917                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
918                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
919                'tier' => new ColumnDefinition('tier', 'ENUM', nullable: false, enumValues: ['gold', 'silver']),
920            ], ['id']),
921            new TableSchema('order', [
922                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
923                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
924                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
925                'total' => new ColumnDefinition('total', 'INT', nullable: false),
926            ], ['id']),
927        ]);
928        $faker = \Faker\Factory::create();
929        $faker->seed(20260101);
930        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
931        $set = $generator->generate(FixturePlan::from('order.customer_id >? customer.id'));
932
933        self::assertSame([], $set->rows('customer'));
934    }
935
936    #[Test]
937    public function testAnUnboundedChildCountCanExceedItsMinimum(): void
938    {
939        $schemas = new StaticSchemaResolver([
940            new TableSchema('order', [
941                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
942                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
943                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
944                'total' => new ColumnDefinition('total', 'INT', nullable: false),
945            ], ['id']),
946            new TableSchema('order_detail', [
947                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
948                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
949                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
950                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
951            ], ['id']),
952        ]);
953        $faker = \Faker\Factory::create();
954        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
955        $counts = array_map(static function (int $seed) use ($faker, $generator): int {
956            $faker->seed($seed);
957            return count($generator->generate(FixturePlan::from('order.id < order_detail.order_id'))->rows('order_detail'));
958        }, range(1, 40));
959
960        self::assertGreaterThan(1, max($counts));
961    }
962
963    #[Test]
964    #[\PHPUnit\Framework\Attributes\DataProvider('providerGenerationSeeds')]
965    public function testAOneToOneChildIsNeverGeneratedMoreThanOnce(int $seed): void
966    {
967        $schemas = new StaticSchemaResolver([
968            new TableSchema('order', [
969                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
970                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
971                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
972                'total' => new ColumnDefinition('total', 'INT', nullable: false),
973            ], ['id']),
974            new TableSchema('order_shipping', [
975                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
976                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
977                'carrier' => new ColumnDefinition('carrier', 'VARCHAR', nullable: false, length: 50),
978            ], ['id']),
979        ]);
980        $faker = \Faker\Factory::create();
981        $faker->seed($seed);
982        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
983        $set = $generator->generate(FixturePlan::from('order.id - order_shipping.order_id'));
984
985        self::assertCount(1, $set->rows('order_shipping'));
986    }
987
988    #[Test]
989    public function testATableReachableOnlyBackwardsAlongARelationIsStillPartOfTheWalk(): void
990    {
991        $schemas = new StaticSchemaResolver([
992            new TableSchema('order', [
993                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
994                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
995                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
996                'total' => new ColumnDefinition('total', 'INT', nullable: false),
997            ], ['id']),
998            new TableSchema('order_detail', [
999                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
1000                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
1001                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
1002                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
1003            ], ['id']),
1004            new TableSchema('product', [
1005                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
1006                'name' => new ColumnDefinition('name', 'VARCHAR', nullable: false, length: 255),
1007            ], ['id']),
1008        ]);
1009        $faker = \Faker\Factory::create();
1010        $faker->seed(20260101);
1011        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1012        $set = $generator->generate(
1013            FixturePlan::from('order.id < order_detail.order_id, product.id ?< order_detail.product_id')
1014        );
1015
1016        self::assertSame([], $set->rows('product'));
1017    }
1018    /**
1019     * @return list<array{int}>
1020     */
1021    public static function providerGenerationSeeds(): array
1022    {
1023        return array_map(static fn (int $seed): array => [$seed], range(1, 40));
1024    }
1025
1026    public function testGenerateMixedChoiceRows(): void
1027    {
1028
1029        $schemas = new StaticSchemaResolver([
1030            new TableSchema('comments', [
1031                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1032                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1033                'target_id' => new ColumnDefinition('target_id', 'INT'),
1034                'tenant' => new ColumnDefinition('tenant', 'INT'),
1035                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1036            ]),
1037            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1038            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1039            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1040        ]);
1041        $faker = \Faker\Factory::create();
1042        $faker->seed(42);
1043        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1044
1045        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.target_id > posts.id } 'video' { comments.target_id > videos.id } 'none' {} }");
1046        $set = $generator->generate($plan, ['comments' => [['kind' => 'post'], ['kind' => 'video'], ['kind' => 'post'], ['kind' => 'none']]]);
1047        self::assertSame(['post', 'video', 'post', 'none'], array_column($set->rows('comments'), 'kind'));
1048        self::assertSame([1, 1, 2, null], array_column($set->rows('comments'), 'target_id'));
1049        self::assertSame([1, 2], array_column($set->rows('posts'), 'id'));
1050        self::assertSame([1], array_column($set->rows('videos'), 'id'));
1051        self::assertSame(['comments', 'posts', 'videos'], $set->tables());
1052
1053    }
1054
1055    public function testGenerateChildTablesSelectedPerPayment(): void
1056    {
1057
1058        $schemas = new StaticSchemaResolver([
1059            new TableSchema('payments', [
1060                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1061                'method' => new ColumnDefinition('method', 'VARCHAR', length: 20),
1062            ]),
1063            new TableSchema('cards', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1064            new TableSchema('banks', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1065            new TableSchema('receipts', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1066        ]);
1067        $faker = \Faker\Factory::create();
1068        $faker->seed(42);
1069        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1070
1071        $plan = FixturePlan::from("choice payments.method { 'card' { payments.id - cards.payment_id } 'bank' { payments.id - banks.payment_id } }");
1072        $set = $generator->generate($plan, ['payments' => [['method' => 'card'], ['method' => 'bank'], ['method' => 'card']]]);
1073        self::assertSame([1, 3], array_column($set->rows('cards'), 'payment_id'));
1074        self::assertSame([2], array_column($set->rows('banks'), 'payment_id'));
1075
1076    }
1077
1078    public function testGenerateAllRelationsInTheSelectedBranch(): void
1079    {
1080
1081        $schemas = new StaticSchemaResolver([
1082            new TableSchema('payments', [
1083                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1084                'method' => new ColumnDefinition('method', 'VARCHAR', length: 20),
1085            ]),
1086            new TableSchema('cards', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1087            new TableSchema('banks', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1088            new TableSchema('receipts', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1089        ]);
1090        $faker = \Faker\Factory::create();
1091        $faker->seed(42);
1092        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1093
1094        $plan = FixturePlan::from("choice payments.method { 'card' { payments.id - cards.payment_id, payments.id < receipts.payment_id } 'bank' { payments.id - banks.payment_id } }");
1095        $set = $generator->generate($plan, ['payments' => ['id' => 8, 'method' => 'card'], 'receipts' => 3]);
1096        self::assertSame([8], array_column($set->rows('cards'), 'payment_id'));
1097        self::assertSame([8, 8, 8], array_column($set->rows('receipts'), 'payment_id'));
1098        self::assertSame([], $set->rows('banks'));
1099
1100    }
1101
1102    public function testGenerateInversePolymorphicChildren(): void
1103    {
1104
1105        $schemas = new StaticSchemaResolver([
1106            new TableSchema('comments', [
1107                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1108                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1109                'target_id' => new ColumnDefinition('target_id', 'INT'),
1110                'tenant' => new ColumnDefinition('tenant', 'INT'),
1111                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1112            ]),
1113            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1114            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1115            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1116        ]);
1117        $faker = \Faker\Factory::create();
1118        $faker->seed(42);
1119        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1120
1121        $plan = FixturePlan::from("posts, choice comments.kind { 'post' { comments.target_id > posts.id } 'video' { comments.target_id > videos.id } }");
1122        $set = $generator->generate($plan, ['posts' => ['id' => 9], 'comments' => 2]);
1123        self::assertSame(['post', 'post'], array_column($set->rows('comments'), 'kind'));
1124        self::assertSame([9, 9], array_column($set->rows('comments'), 'target_id'));
1125        self::assertSame([], $set->rows('videos'));
1126
1127    }
1128
1129    public function testGenerateInverseSubtypeParent(): void
1130    {
1131
1132        $schemas = new StaticSchemaResolver([
1133            new TableSchema('payments', [
1134                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1135                'method' => new ColumnDefinition('method', 'VARCHAR', length: 20),
1136            ]),
1137            new TableSchema('cards', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1138            new TableSchema('banks', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1139            new TableSchema('receipts', ['payment_id' => new ColumnDefinition('payment_id', 'INT')]),
1140        ]);
1141        $faker = \Faker\Factory::create();
1142        $faker->seed(42);
1143        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1144
1145        $plan = FixturePlan::from("cards, choice payments.method { 'card' { payments.id - cards.payment_id } 'bank' { payments.id - banks.payment_id } }");
1146        $set = $generator->generate($plan);
1147        self::assertSame(['card'], array_column($set->rows('payments'), 'method'));
1148        self::assertSame(array_column($set->rows('payments'), 'id'), array_column($set->rows('cards'), 'payment_id'));
1149        self::assertSame([], $set->rows('banks'));
1150
1151    }
1152
1153    public function testGeneratePolymorphicJunctionRows(): void
1154    {
1155
1156        $schemas = new StaticSchemaResolver([
1157            new TableSchema('comments', [
1158                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1159                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1160                'target_id' => new ColumnDefinition('target_id', 'INT'),
1161                'tenant' => new ColumnDefinition('tenant', 'INT'),
1162                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1163            ]),
1164            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1165            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1166            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1167        ]);
1168        $faker = \Faker\Factory::create();
1169        $faker->seed(42);
1170        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1171
1172        $plan = FixturePlan::from("tags.id < comments.tag_id, choice comments.kind { 'post' { comments.target_id > posts.id } 'video' { comments.target_id > videos.id } }");
1173        $set = $generator->generate($plan, ['tags' => ['id' => 12], 'comments' => [['kind' => 'post'], ['kind' => 'video']]]);
1174        self::assertSame([12, 12], array_column($set->rows('comments'), 'tag_id'));
1175        self::assertCount(1, $set->rows('tags'));
1176        self::assertCount(1, $set->rows('posts'));
1177        self::assertCount(1, $set->rows('videos'));
1178
1179    }
1180
1181    public function testGenerateCompositeChoiceKeys(): void
1182    {
1183
1184        $schemas = new StaticSchemaResolver([
1185            new TableSchema('comments', [
1186                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1187                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1188                'target_id' => new ColumnDefinition('target_id', 'INT'),
1189                'tenant' => new ColumnDefinition('tenant', 'INT'),
1190                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1191            ]),
1192            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1193            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1194            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1195        ]);
1196        $faker = \Faker\Factory::create();
1197        $faker->seed(42);
1198        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1199
1200        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.(tenant, target_id) > posts.(tenant, id) } 'video' { comments.(tenant, target_id) > videos.(tenant, id) } }");
1201        $set = $generator->generate($plan, ['comments' => [['kind' => 'post'], ['kind' => 'video']], 'posts' => ['tenant' => 7], 'videos' => ['tenant' => 8]]);
1202        self::assertSame([7, 8], array_column($set->rows('comments'), 'tenant'));
1203        self::assertSame([1, 1], array_column($set->rows('comments'), 'target_id'));
1204
1205    }
1206
1207    public function testGenerateExplicitReferencesWithoutInventingParents(): void
1208    {
1209
1210        $schemas = new StaticSchemaResolver([
1211            new TableSchema('comments', [
1212                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1213                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1214                'target_id' => new ColumnDefinition('target_id', 'INT'),
1215                'tenant' => new ColumnDefinition('tenant', 'INT'),
1216                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1217            ]),
1218            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1219            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1220            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1221        ]);
1222        $faker = \Faker\Factory::create();
1223        $faker->seed(42);
1224        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1225
1226        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.target_id > posts.id } 'video' { comments.target_id > videos.id } }");
1227        $set = $generator->generate($plan, ['comments' => ['kind' => 'video', 'target_id' => 77]]);
1228        self::assertSame([77], array_column($set->rows('comments'), 'target_id'));
1229        self::assertSame([], $set->rows('posts'));
1230        self::assertSame([], $set->rows('videos'));
1231
1232    }
1233
1234    public function testGenerateFallbackAndNullBranches(): void
1235    {
1236
1237        $schemas = new StaticSchemaResolver([
1238            new TableSchema('comments', [
1239                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1240                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1241                'target_id' => new ColumnDefinition('target_id', 'INT'),
1242                'tenant' => new ColumnDefinition('tenant', 'INT'),
1243                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1244            ]),
1245            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1246            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1247            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1248        ]);
1249        $faker = \Faker\Factory::create();
1250        $faker->seed(42);
1251        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1252
1253        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.target_id > posts.id } null {} otherwise {} }");
1254        $set = $generator->generate($plan, ['comments' => [['kind' => null], ['kind' => 'external']]]);
1255        self::assertSame([null, 'external'], array_column($set->rows('comments'), 'kind'));
1256        self::assertSame([null, null], array_column($set->rows('comments'), 'target_id'));
1257        self::assertSame([], $set->rows('posts'));
1258
1259    }
1260
1261    public function testGenerateOptionalParentsWithNullReferences(): void
1262    {
1263
1264        $schemas = new StaticSchemaResolver([
1265            new TableSchema('comments', [
1266                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1267                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1268                'target_id' => new ColumnDefinition('target_id', 'INT'),
1269                'tenant' => new ColumnDefinition('tenant', 'INT'),
1270                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1271            ]),
1272            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1273            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1274            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1275        ]);
1276        $faker = \Faker\Factory::create();
1277        $faker->seed(42);
1278        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1279
1280        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.target_id >? posts.id } }");
1281        $set = $generator->generate($plan, ['comments' => ['kind' => 'post']]);
1282        self::assertSame([null], array_column($set->rows('comments'), 'target_id'));
1283        self::assertSame([], $set->rows('posts'));
1284
1285    }
1286
1287    public function testGenerateInverseEquivalentCasesOncePerChild(): void
1288    {
1289
1290        $schemas = new StaticSchemaResolver([
1291            new TableSchema('comments', [
1292                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1293                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1294                'target_id' => new ColumnDefinition('target_id', 'INT'),
1295                'tenant' => new ColumnDefinition('tenant', 'INT'),
1296                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1297            ]),
1298            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1299            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1300            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1301        ]);
1302        $faker = \Faker\Factory::create();
1303        $faker->seed(42);
1304        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1305
1306        $plan = FixturePlan::from("posts, choice comments.kind { 'review' { comments.target_id > posts.id } 'comment' { comments.target_id > posts.id } }");
1307        $set = $generator->generate($plan, ['comments' => [['kind' => 'review'], ['kind' => 'comment']]]);
1308        self::assertSame(['review', 'comment'], array_column($set->rows('comments'), 'kind'));
1309        self::assertCount(1, $set->rows('posts'));
1310        self::assertSame([1, 1], array_column($set->rows('comments'), 'target_id'));
1311
1312    }
1313
1314    public function testGenerateUnknownDiscriminatorReportsItsColumn(): void
1315    {
1316
1317        $schemas = new StaticSchemaResolver([
1318            new TableSchema('comments', [
1319                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1320                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1321                'target_id' => new ColumnDefinition('target_id', 'INT'),
1322                'tenant' => new ColumnDefinition('tenant', 'INT'),
1323                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1324            ]),
1325            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1326            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1327            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1328        ]);
1329        $faker = \Faker\Factory::create();
1330        $faker->seed(42);
1331        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1332
1333        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.target_id > posts.id } }");
1334        $this->expectException(\SqlFixture\Fixture\Choice\ChoiceValueException::class);
1335        $this->expectExceptionMessage('comments.kind');
1336        $generator->generate($plan, ['comments' => ['kind' => 'unknown']]);
1337
1338    }
1339
1340    public function testGenerateConflictingInverseDiscriminatorIsRejected(): void
1341    {
1342
1343        $schemas = new StaticSchemaResolver([
1344            new TableSchema('comments', [
1345                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1346                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1347                'target_id' => new ColumnDefinition('target_id', 'INT'),
1348                'tenant' => new ColumnDefinition('tenant', 'INT'),
1349                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1350            ]),
1351            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1352            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1353            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1354        ]);
1355        $faker = \Faker\Factory::create();
1356        $faker->seed(42);
1357        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1358
1359        $plan = FixturePlan::from("posts, choice comments.kind { 'post' { comments.target_id > posts.id } 'video' { comments.target_id > videos.id } }");
1360        $this->expectException(\SqlFixture\Fixture\Choice\ChoiceValueException::class);
1361        $generator->generate($plan, ['comments' => ['kind' => 'video']]);
1362
1363    }
1364
1365    public function testGenerateUnknownDiscriminatorColumnsAreRejected(): void
1366    {
1367
1368        $schemas = new StaticSchemaResolver([
1369            new TableSchema('comments', [
1370                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1371                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1372                'target_id' => new ColumnDefinition('target_id', 'INT'),
1373                'tenant' => new ColumnDefinition('tenant', 'INT'),
1374                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1375            ]),
1376            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1377            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1378            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1379        ]);
1380        $faker = \Faker\Factory::create();
1381        $faker->seed(42);
1382        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1383
1384        $plan = FixturePlan::from("choice comments.missing { 'post' { comments.target_id > posts.id } }");
1385        $this->expectException(\SqlFixture\Fixture\Exception\UnknownPlanColumnException::class);
1386        $generator->generate($plan);
1387
1388    }
1389
1390    public function testGeneratePartialCompositeOverridesAreCarriedToTheParent(): void
1391    {
1392
1393        $schemas = new StaticSchemaResolver([
1394            new TableSchema('comments', [
1395                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1396                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1397                'target_id' => new ColumnDefinition('target_id', 'INT'),
1398                'tenant' => new ColumnDefinition('tenant', 'INT'),
1399                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1400            ]),
1401            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1402            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1403            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1404        ]);
1405        $faker = \Faker\Factory::create();
1406        $faker->seed(42);
1407        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1408
1409        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.(tenant, target_id) > posts.(tenant, id) } }");
1410        $set = $generator->generate($plan, ['comments' => ['kind' => 'post', 'tenant' => 42]]);
1411        self::assertSame([42], array_column($set->rows('comments'), 'tenant'));
1412        self::assertSame([42], array_column($set->rows('posts'), 'tenant'));
1413        self::assertSame([1], array_column($set->rows('comments'), 'target_id'));
1414
1415    }
1416
1417    public function testGenerateConflictingParentKeysAreRejected(): void
1418    {
1419
1420        $schemas = new StaticSchemaResolver([
1421            new TableSchema('comments', [
1422                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1423                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1424                'target_id' => new ColumnDefinition('target_id', 'INT'),
1425                'tenant' => new ColumnDefinition('tenant', 'INT'),
1426                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1427            ]),
1428            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1429            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1430            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1431        ]);
1432        $faker = \Faker\Factory::create();
1433        $faker->seed(42);
1434        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1435
1436        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.(tenant, target_id) > posts.(tenant, id) } }");
1437        $this->expectException(\SqlFixture\Fixture\Generation\RelationValueException::class);
1438        $this->expectExceptionMessage('posts.tenant');
1439        $generator->generate($plan, ['comments' => ['kind' => 'post', 'tenant' => 42], 'posts' => ['tenant' => 99]]);
1440
1441    }
1442
1443    public function testGenerateConflictingChildKeysAreRejected(): void
1444    {
1445
1446        $schemas = new StaticSchemaResolver([
1447            new TableSchema('comments', [
1448                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1449                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1450                'target_id' => new ColumnDefinition('target_id', 'INT'),
1451                'tenant' => new ColumnDefinition('tenant', 'INT'),
1452                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1453            ]),
1454            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1455            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1456            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1457        ]);
1458        $faker = \Faker\Factory::create();
1459        $faker->seed(42);
1460        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1461
1462        $plan = FixturePlan::from("posts, choice comments.kind { 'post' { comments.target_id > posts.id } }");
1463        $this->expectException(\SqlFixture\Fixture\Generation\RelationValueException::class);
1464        $generator->generate($plan, ['posts' => ['id' => 42], 'comments' => ['target_id' => 99]]);
1465
1466    }
1467
1468    public function testGenerateRepeatedRolesFailWithoutUnboundedRecursion(): void
1469    {
1470
1471        $schemas = new StaticSchemaResolver([
1472            new TableSchema('comments', [
1473                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1474                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1475                'target_id' => new ColumnDefinition('target_id', 'INT'),
1476                'tenant' => new ColumnDefinition('tenant', 'INT'),
1477                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1478            ]),
1479            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1480            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1481            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1482        ]);
1483        $faker = \Faker\Factory::create();
1484        $faker->seed(42);
1485        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1486
1487        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.target_id > posts.id, comments.tag_id > posts.tenant } }");
1488        $this->expectException(\SqlFixture\Fixture\Generation\RecursiveRelationException::class);
1489        $generator->generate($plan, ['comments' => ['kind' => 'post']]);
1490
1491    }
1492
1493    public function testGenerateSeededCaseSelectionIsReproducible(): void
1494    {
1495        /**
1496         * Destructors of earlier Faker instances reset the shared random seed.
1497         * Collect them before starting the seeded generation below.
1498         */
1499        gc_collect_cycles();
1500
1501        $schemas = new StaticSchemaResolver([
1502            new TableSchema('comments', [
1503                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
1504                'kind' => new ColumnDefinition('kind', 'VARCHAR', length: 20),
1505                'target_id' => new ColumnDefinition('target_id', 'INT'),
1506                'tenant' => new ColumnDefinition('tenant', 'INT'),
1507                'tag_id' => new ColumnDefinition('tag_id', 'INT'),
1508            ]),
1509            new TableSchema('posts', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1510            new TableSchema('videos', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true), 'tenant' => new ColumnDefinition('tenant', 'INT')]),
1511            new TableSchema('tags', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)]),
1512        ]);
1513        $faker = \Faker\Factory::create();
1514        $faker->seed(42);
1515        $generator = new PlanGenerator($schemas, new FixtureGenerator($faker), $faker);
1516
1517        $plan = FixturePlan::from("choice comments.kind { 'post' { comments.target_id > posts.id } 'video' { comments.target_id > videos.id } }");
1518        $first = $generator->generate($plan, ['comments' => 12]);
1519        $faker->seed(42);
1520        $second = $generator->generate($plan, ['comments' => 12]);
1521        self::assertSame($first->rows('comments'), $second->rows('comments'));
1522        self::assertContains('post', array_column($first->rows('comments'), 'kind'));
1523        self::assertContains('video', array_column($first->rows('comments'), 'kind'));
1524
1525    }
1526}
1527