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)]
79final class PlanSchemaValidatorTest extends TestCase
80{
81    #[Test]
82    public function testValidateAPlanMatchingTheSchemaPasses(): void
83    {
84        $schemas = new StaticSchemaResolver([
85            new TableSchema('order', [
86                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
87                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
88                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
89                'total' => new ColumnDefinition('total', 'INT', nullable: false),
90            ], ['id']),
91            new TableSchema('order_detail', [
92                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
93                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
94                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
95                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
96            ], ['id']),
97        ]);
98        $validator = new PlanSchemaValidator($schemas);
99
100        $validator->validate(FixturePlan::from('order.id < order_detail.order_id'));
101
102        $this->expectNotToPerformAssertions();
103    }
104
105    #[Test]
106    #[DataProvider('providerMismatchedPlans')]
107    public function aColumnTheTableDoesNotHaveIsRejected(string $plan, string $expected): void
108    {
109        $schemas = new StaticSchemaResolver([
110            new TableSchema('order', [
111                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
112                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
113                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
114                'total' => new ColumnDefinition('total', 'INT', nullable: false),
115            ], ['id']),
116            new TableSchema('order_detail', [
117                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
118                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
119                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
120                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
121            ], ['id']),
122        ]);
123        $validator = new PlanSchemaValidator($schemas);
124
125        $this->expectException(PlanSchemaException::class);
126        $this->expectExceptionMessage($expected);
127
128        $validator->validate(FixturePlan::from($plan));
129    }
130
131    /**
132     * @return array<string, array{string, string}>
133     */
134    public static function providerMismatchedPlans(): array
135    {
136        return [
137            'parent column misspelt' => [
138                'order.idd < order_detail.order_id',
139                'order has no column idd',
140            ],
141            'child column misspelt' => [
142                'order.id < order_detail.oder_id',
143                'order_detail has no column oder_id',
144            ],
145            'column belongs to another table' => [
146                'order.quantity < order_detail.order_id',
147                'order has no column quantity',
148            ],
149            'one of a composite key is wrong' => [
150                'order.(id, nope) < order_detail.(order_id, quantity)',
151                'order has no column nope',
152            ],
153        ];
154    }
155
156    #[Test]
157    public function testATableTheResolverDoesNotKnowIsRejected(): void
158    {
159        $schemas = new StaticSchemaResolver([
160            new TableSchema('order', [
161                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
162                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
163                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
164                'total' => new ColumnDefinition('total', 'INT', nullable: false),
165            ], ['id']),
166            new TableSchema('order_detail', [
167                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
168                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
169                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
170                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
171            ], ['id']),
172        ]);
173        $validator = new PlanSchemaValidator($schemas);
174
175        $this->expectException(SchemaNotFoundException::class);
176        $this->expectExceptionMessage('Schema not found for table: nope');
177
178        $validator->validate(FixturePlan::from('nope.id < order_detail.order_id'));
179    }
180
181    #[Test]
182    public function testATableNamedWithoutAnyRelationIsCheckedToo(): void
183    {
184        $schemas = new StaticSchemaResolver([
185            new TableSchema('order', [
186                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
187                'customer_id' => new ColumnDefinition('customer_id', 'INT', nullable: false, unsigned: true),
188                'status' => new ColumnDefinition('status', 'VARCHAR', nullable: false, length: 20),
189                'total' => new ColumnDefinition('total', 'INT', nullable: false),
190            ], ['id']),
191            new TableSchema('order_detail', [
192                'id' => new ColumnDefinition('id', 'INT', nullable: false, unsigned: true, autoIncrement: true),
193                'order_id' => new ColumnDefinition('order_id', 'INT', nullable: false, unsigned: true),
194                'product_id' => new ColumnDefinition('product_id', 'INT', nullable: false, unsigned: true),
195                'quantity' => new ColumnDefinition('quantity', 'INT', nullable: false),
196            ], ['id']),
197        ]);
198        $validator = new PlanSchemaValidator($schemas);
199
200        $this->expectException(SchemaNotFoundException::class);
201        $this->expectExceptionMessage('Schema not found for table: nope');
202
203        $validator->validate(FixturePlan::from('order.id < order_detail.order_id, nope'));
204    }
205
206    #[Test]
207    public function testAGeneratedColumnCannotBeLinked(): void
208    {
209        $resolver = new StaticSchemaResolver([
210            new TableSchema('order', [
211                'id' => new ColumnDefinition('id', 'INT', autoIncrement: true),
212                'code' => new ColumnDefinition('code', 'VARCHAR', length: 10, generated: true),
213            ], ['id']),
214            new TableSchema('order_detail', [
215                'order_code' => new ColumnDefinition('order_code', 'VARCHAR', length: 10),
216            ]),
217        ]);
218
219        $this->expectException(\SqlFixture\Fixture\Exception\GeneratedColumnReferenceException::class);
220        $this->expectExceptionMessage('order.code is a generated column');
221
222        (new PlanSchemaValidator($resolver))->validate(FixturePlan::from('order.code < order_detail.order_code'));
223    }
224
225    #[Test]
226    public function testAGeneratedColumnCannotBeWrittenIntoEither(): void
227    {
228        $resolver = new StaticSchemaResolver([
229            new TableSchema('order', ['id' => new ColumnDefinition('id', 'INT', autoIncrement: true)], ['id']),
230            new TableSchema('order_detail', [
231                'order_id' => new ColumnDefinition('order_id', 'INT', generated: true),
232            ]),
233        ]);
234
235        $this->expectException(\SqlFixture\Fixture\Exception\GeneratedColumnReferenceException::class);
236        $this->expectExceptionMessage('order_detail.order_id is a generated column');
237
238        (new PlanSchemaValidator($resolver))->validate(FixturePlan::from('order.id < order_detail.order_id'));
239    }
240}
241