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

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Fixture;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Test;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use SqlFixture\Fixture\PlanSchemaException;
13use SqlFixture\Fixture\PlanSchemaValidator;
14use SqlFixture\Plan\ColumnRef;
15use SqlFixture\Plan\FixturePlan;
16use SqlFixture\Plan\PlanParser;
17use SqlFixture\Plan\Relation;
18use SqlFixture\Plan\RelationKind;
19use SqlFixture\Plan\RelationSide;
20use SqlFixture\Platform\MySql\MySqlSchemaParser;
21use SqlFixture\Schema\ColumnDefinition;
22use SqlFixture\Schema\SchemaNotFoundException;
23use SqlFixture\Schema\StaticSchemaResolver;
24use SqlFixture\Schema\TableSchema;
25
26#[CoversClass(PlanSchemaValidator::class)]
27#[UsesClass(PlanSchemaException::class)]
28#[UsesClass(FixturePlan::class)]
29#[UsesClass(PlanParser::class)]
30#[UsesClass(Relation::class)]
31#[UsesClass(ColumnRef::class)]
32#[UsesClass(RelationKind::class)]
33#[UsesClass(RelationSide::class)]
34#[UsesClass(MySqlSchemaParser::class)]
35#[UsesClass(StaticSchemaResolver::class)]
36#[UsesClass(SchemaNotFoundException::class)]
37#[UsesClass(TableSchema::class)]
38#[UsesClass(ColumnDefinition::class)]
39#[CoversClass(\SqlFixture\Fixture\Validation\EndpointValidator::class)]
40#[UsesClass(\SqlFixture\Plan\PlanPrinter::class)]
41#[UsesClass(\SqlFixture\Plan\PlanSyntaxException::class)]
42#[UsesClass(\SqlFixture\Schema\SchemaParseException::class)]
43#[UsesClass(\SqlFixture\Schema\SchemaParserInterface::class)]
44#[UsesClass(\SqlFixture\Schema\SchemaResolverInterface::class)]
45#[UsesClass(\SqlFixture\Schema\TableIdentifier::class)]
46#[UsesClass(\SqlFixture\Plan\Parsing\PlanStatements::class)]
47#[UsesClass(\SqlFixture\Plan\Parsing\RelationCursor::class)]
48#[UsesClass(\SqlFixture\Plan\Parsing\RelationReader::class)]
49#[UsesClass(\SqlFixture\Plan\PlanStructureException::class)]
50#[UsesClass(\SqlFixture\Plan\Printing\PlanTables::class)]
51#[UsesClass(\SqlFixture\Plan\Printing\RelationGroups::class)]
52#[UsesClass(\SqlFixture\Plan\Printing\StatementPrinter::class)]
53#[UsesClass(\SqlFixture\Plan\Validation\PlanValidation::class)]
54#[UsesClass(\SqlFixture\Plan\Validation\TableName::class)]
55#[UsesClass(\SqlFixture\Platform\MySql\Schema\ColumnParser::class)]
56#[UsesClass(\SqlFixture\Platform\MySql\Schema\DefaultExpression::class)]
57#[UsesClass(\SqlFixture\Platform\MySql\Schema\DefinitionIntegrity::class)]
58#[UsesClass(\SqlFixture\Platform\MySql\Schema\TableDefinition::class)]
59#[UsesClass(\SqlFixture\Platform\MySql\Schema\TypeParameters::class)]
60#[UsesClass(\SqlFixture\Schema\TypeShape::class)]
61#[UsesClass(\SqlFixture\Platform\MySql\Schema\TableDefinitionInput::class)]
62#[UsesClass(\SqlFixture\Fixture\Exception\GeneratedColumnReferenceException::class)]
63#[UsesClass(\SqlFixture\Fixture\Exception\MissingRelationValueException::class)]
64#[UsesClass(\SqlFixture\Fixture\Exception\UnknownPlanColumnException::class)]
65#[UsesClass(\SqlFixture\Plan\Exception\EmptyPlanException::class)]
66#[UsesClass(\SqlFixture\Plan\Exception\EmptyTableNameException::class)]
67#[UsesClass(\SqlFixture\Plan\Exception\MissingEndpointColumnsException::class)]
68#[UsesClass(\SqlFixture\Plan\Exception\InvalidTableNameException::class)]
69#[UsesClass(\SqlFixture\Plan\Exception\UnbalancedBracketsException::class)]
70#[UsesClass(\SqlFixture\Plan\Exception\UnexpectedPlanTokenException::class)]
71#[UsesClass(\SqlFixture\Plan\Exception\UnsupportedManyToManyException::class)]
72#[UsesClass(\SqlFixture\Plan\Exception\CompositeArityMismatchException::class)]
73#[UsesClass(\SqlFixture\Plan\Exception\DuplicateColumnBindingException::class)]
74#[UsesClass(\SqlFixture\Plan\Exception\CyclicDependencyException::class)]
75#[UsesClass(\SqlFixture\Plan\Exception\UnboundedSelfReferenceException::class)]
76#[UsesClass(\SqlFixture\Schema\Exception\InvalidSqlException::class)]
77#[UsesClass(\SqlFixture\Schema\Exception\ExpectedCreateTableException::class)]
78#[UsesClass(\SqlFixture\Schema\Exception\MissingColumnDefinitionsException::class)]
79#[UsesClass(\SqlFixture\Plan\Choice\ChoiceValidation::class)]
80#[UsesClass(\SqlFixture\Plan\Choice\PlanContents::class)]
81final class PlanSchemaValidatorTest extends TestCase
82{
83    #[Test]
84    public function testValidateAPlanMatchingTheSchemaPasses(): void
85    {
86        $schemas = new StaticSchemaResolver([
87            new TableSchema('order', [
88                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
89                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
90                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
91                'total' => new ColumnDefinition('total', 'INT', nullable: false),
92            ], ['id']),
93            new TableSchema('order_detail', [
94                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
95                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
96                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
97                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
98            ], ['id']),
99        ]);
100        $validator = new PlanSchemaValidator($schemas);
101
102        $validator->validate(FixturePlan::from('order.id < order_detail.order_id'));
103
104        $this->expectNotToPerformAssertions();
105    }
106
107    #[Test]
108    #[DataProvider('providerMismatchedPlans')]
109    public function aColumnTheTableDoesNotHaveIsRejected(string $plan, string $expected): void
110    {
111        $schemas = new StaticSchemaResolver([
112            new TableSchema('order', [
113                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
114                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
115                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
116                'total' => new ColumnDefinition('total', 'INT', nullable: false),
117            ], ['id']),
118            new TableSchema('order_detail', [
119                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
120                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
121                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
122                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
123            ], ['id']),
124        ]);
125        $validator = new PlanSchemaValidator($schemas);
126
127        $this->expectException(PlanSchemaException::class);
128        $this->expectExceptionMessage($expected);
129
130        $validator->validate(FixturePlan::from($plan));
131    }
132
133    /**
134     * @return array<string, array{string, string}>
135     */
136    public static function providerMismatchedPlans(): array
137    {
138        return [
139            'parent column misspelt' => [
140                'order.idd < order_detail.order_id',
141                'order has no column idd',
142            ],
143            'child column misspelt' => [
144                'order.id < order_detail.oder_id',
145                'order_detail has no column oder_id',
146            ],
147            'column belongs to another table' => [
148                'order.quantity < order_detail.order_id',
149                'order has no column quantity',
150            ],
151            'one of a composite key is wrong' => [
152                'order.(id, nope) < order_detail.(order_id, quantity)',
153                'order has no column nope',
154            ],
155        ];
156    }
157
158    #[Test]
159    public function testATableTheResolverDoesNotKnowIsRejected(): void
160    {
161        $schemas = new StaticSchemaResolver([
162            new TableSchema('order', [
163                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
164                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
165                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
166                'total' => new ColumnDefinition('total', 'INT', nullable: false),
167            ], ['id']),
168            new TableSchema('order_detail', [
169                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
170                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
171                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
172                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
173            ], ['id']),
174        ]);
175        $validator = new PlanSchemaValidator($schemas);
176
177        $this->expectException(SchemaNotFoundException::class);
178        $this->expectExceptionMessage('Schema not found for table: nope');
179
180        $validator->validate(FixturePlan::from('nope.id < order_detail.order_id'));
181    }
182
183    #[Test]
184    public function testATableNamedWithoutAnyRelationIsCheckedToo(): void
185    {
186        $schemas = new StaticSchemaResolver([
187            new TableSchema('order', [
188                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
189                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
190                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
191                'total' => new ColumnDefinition('total', 'INT', nullable: false),
192            ], ['id']),
193            new TableSchema('order_detail', [
194                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
195                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
196                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
197                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
198            ], ['id']),
199        ]);
200        $validator = new PlanSchemaValidator($schemas);
201
202        $this->expectException(SchemaNotFoundException::class);
203        $this->expectExceptionMessage('Schema not found for table: nope');
204
205        $validator->validate(FixturePlan::from('order.id < order_detail.order_id, nope'));
206    }
207
208    #[Test]
209    public function testAGeneratedColumnCannotBeLinked(): void
210    {
211        $resolver = new StaticSchemaResolver([
212            new TableSchema('order', [
213                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
214                'code' => new ColumnDefinition('code', 'VARCHAR', length: 10, generated: true),
215            ], ['id']),
216            new TableSchema('order_detail', [
217                'order_code' => new ColumnDefinition('order_code', 'VARCHAR', length: 10),
218            ]),
219        ]);
220
221        $this->expectException(\SqlFixture\Fixture\Exception\GeneratedColumnReferenceException::class);
222        $this->expectExceptionMessage('order.code is a generated column');
223
224        (new PlanSchemaValidator($resolver))->validate(FixturePlan::from('order.code < order_detail.order_code'));
225    }
226
227    #[Test]
228    public function testAGeneratedColumnCannotBeWrittenIntoEither(): void
229    {
230        $resolver = new StaticSchemaResolver([
231            new TableSchema('order', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)], ['id']),
232            new TableSchema('order_detail', [
233                'order_id' => new ColumnDefinition('order_id', 'INT', generated: true),
234            ]),
235        ]);
236
237        $this->expectException(\SqlFixture\Fixture\Exception\GeneratedColumnReferenceException::class);
238        $this->expectExceptionMessage('order_detail.order_id is a generated column');
239
240        (new PlanSchemaValidator($resolver))->validate(FixturePlan::from('order.id < order_detail.order_id'));
241    }
242}
243