packages/sql-fixture/tests/Unit/Plan/PlanParserTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Plan;
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\Plan\ColumnRef;
13use SqlFixture\Plan\FixturePlan;
14use SqlFixture\Plan\PlanParser;
15use SqlFixture\Plan\PlanSyntaxException;
16use SqlFixture\Plan\Relation;
17use SqlFixture\Plan\RelationKind;
18use SqlFixture\Plan\RelationSide;
19
20#[CoversClass(PlanParser::class)]
21#[UsesClass(FixturePlan::class)]
22#[UsesClass(Relation::class)]
23#[UsesClass(ColumnRef::class)]
24#[UsesClass(RelationKind::class)]
25#[UsesClass(RelationSide::class)]
26#[UsesClass(PlanSyntaxException::class)]
27#[CoversClass(\SqlFixture\Plan\Parsing\PlanStatements::class)]
28#[CoversClass(\SqlFixture\Plan\Parsing\RelationCursor::class)]
29#[CoversClass(\SqlFixture\Plan\Parsing\RelationReader::class)]
30#[UsesClass(\SqlFixture\Plan\PlanPrinter::class)]
31#[UsesClass(\SqlFixture\Plan\PlanStructureException::class)]
32#[UsesClass(\SqlFixture\Plan\Printing\PlanTables::class)]
33#[UsesClass(\SqlFixture\Plan\Printing\RelationGroups::class)]
34#[UsesClass(\SqlFixture\Plan\Printing\StatementPrinter::class)]
35#[UsesClass(\SqlFixture\Plan\Validation\PlanValidation::class)]
36#[UsesClass(\SqlFixture\Plan\Validation\TableName::class)]
37#[UsesClass(\SqlFixture\Plan\Exception\EmptyPlanException::class)]
38#[UsesClass(\SqlFixture\Plan\Exception\EmptyTableNameException::class)]
39#[UsesClass(\SqlFixture\Plan\Exception\MissingEndpointColumnsException::class)]
40#[UsesClass(\SqlFixture\Plan\Exception\InvalidTableNameException::class)]
41#[UsesClass(\SqlFixture\Plan\Exception\UnbalancedBracketsException::class)]
42#[UsesClass(\SqlFixture\Plan\Exception\UnexpectedPlanTokenException::class)]
43#[UsesClass(\SqlFixture\Plan\Exception\UnsupportedManyToManyException::class)]
44#[UsesClass(\SqlFixture\Plan\Exception\CompositeArityMismatchException::class)]
45#[UsesClass(\SqlFixture\Plan\Exception\DuplicateColumnBindingException::class)]
46#[UsesClass(\SqlFixture\Plan\Exception\CyclicDependencyException::class)]
47#[UsesClass(\SqlFixture\Plan\Exception\UnboundedSelfReferenceException::class)]
48final class PlanParserTest extends TestCase
49{
50    #[Test]
51    public function testParseABareTableNameIsAPlanWithNoRelations(): void
52    {
53        $plan = (new PlanParser())->parse('order');
54
55        self::assertSame([], $plan->relations);
56        self::assertSame(['order'], $plan->tables);
57    }
58
59    #[Test]
60    public function testReadsAOneToManyRelation(): void
61    {
62        $plan = (new PlanParser())->parse('order.id < order_detail.order_id');
63
64        self::assertCount(1, $plan->relations);
65        self::assertSame(RelationKind::OneToMany, $plan->relations[0]->kind);
66        self::assertSame('order.id', $plan->relations[0]->left->toString());
67        self::assertSame('order_detail.order_id', $plan->relations[0]->right->toString());
68    }
69
70    #[Test]
71    public function testReadsAManyToOneRelation(): void
72    {
73        $plan = (new PlanParser())->parse('order_detail.order_id > order.id');
74
75        self::assertSame(RelationKind::ManyToOne, $plan->relations[0]->kind);
76        self::assertSame('order', $plan->relations[0]->parent()->table);
77    }
78
79    #[Test]
80    public function testReadsAOneToOneRelation(): void
81    {
82        $plan = (new PlanParser())->parse('order.id - order_shipping.order_id');
83
84        self::assertSame(RelationKind::OneToOne, $plan->relations[0]->kind);
85        self::assertFalse($plan->relations[0]->childIsCollection());
86    }
87
88    #[Test]
89    public function testReadsACompositeEndpoint(): void
90    {
91        $plan = (new PlanParser())->parse('order.(shop_id, no) < order_detail.(shop_id, order_no)');
92
93        self::assertSame(['shop_id', 'no'], $plan->relations[0]->left->columns);
94        self::assertSame(['shop_id', 'order_no'], $plan->relations[0]->right->columns);
95    }
96
97    #[Test]
98    public function testAGroupedTargetExpandsToOneRelationPerEndpoint(): void
99    {
100        $plan = (new PlanParser())->parse('order.id < [order_detail.order_id, shipment.order_id]');
101
102        self::assertCount(2, $plan->relations);
103        self::assertSame('order_detail', $plan->relations[0]->right->table);
104        self::assertSame('shipment', $plan->relations[1]->right->table);
105        self::assertSame('order.id', $plan->relations[1]->left->toString());
106    }
107
108    #[Test]
109    public function testCommasSeparateRelations(): void
110    {
111        $plan = (new PlanParser())->parse('order.id < order_detail.order_id, order.customer_id > customer.id');
112
113        self::assertCount(2, $plan->relations);
114        self::assertSame(['order', 'order_detail', 'customer'], $plan->tables);
115    }
116
117    #[Test]
118    public function testNewlinesSeparateRelations(): void
119    {
120        $plan = (new PlanParser())->parse("order.id < order_detail.order_id\norder_detail.product_id > product.id");
121
122        self::assertCount(2, $plan->relations);
123    }
124
125    #[Test]
126    public function testSemicolonsSeparateRelations(): void
127    {
128        $plan = (new PlanParser())->parse('order.id < order_detail.order_id; order.customer_id > customer.id');
129
130        self::assertCount(2, $plan->relations);
131    }
132
133    #[Test]
134    public function testCommasInsideBracketsDoNotSeparateRelations(): void
135    {
136        $plan = (new PlanParser())->parse('order.(a, b) < [x.(a, b), y.(a, b)]');
137
138        self::assertCount(2, $plan->relations);
139    }
140
141    #[Test]
142    public function testTablesAreListedInFirstMentionedOrderWithoutRepeats(): void
143    {
144        $plan = (new PlanParser())->parse(
145            'order.id < order_detail.order_id, order_detail.product_id > product.id'
146        );
147
148        self::assertSame(['order', 'order_detail', 'product'], $plan->tables);
149    }
150
151    #[Test]
152    public function testAMarkerBeforeTheOperatorMarksTheLeftSideOptional(): void
153    {
154        $plan = (new PlanParser())->parse('order.id ?< order_detail.order_id');
155
156        self::assertTrue($plan->relations[0]->leftOptional);
157        self::assertFalse($plan->relations[0]->rightOptional);
158    }
159
160    #[Test]
161    public function testAMarkerAfterTheOperatorMarksTheRightSideOptional(): void
162    {
163        $plan = (new PlanParser())->parse('order_detail.order_id >? order.id');
164
165        self::assertTrue($plan->relations[0]->rightOptional);
166        self::assertTrue($plan->relations[0]->parentIsOptional());
167    }
168
169    #[Test]
170    #[DataProvider('providerQuotedForms')]
171    public function identifiersMayBeQuoted(string $plan): void
172    {
173        $parsed = (new PlanParser())->parse($plan);
174
175        self::assertSame('order.id', $parsed->relations[0]->left->toString());
176    }
177
178    /**
179     * @return array<string, array{string}>
180     */
181    public static function providerQuotedForms(): array
182    {
183        return [
184            'backticks' => ['`order`.`id` < order_detail.order_id'],
185            'double quotes' => ['"order"."id" < order_detail.order_id'],
186            'mixed' => ['`order`."id" < order_detail.order_id'],
187        ];
188    }
189
190    #[Test]
191    public function testSurroundingWhitespaceIsIgnored(): void
192    {
193        $plan = (new PlanParser())->parse('   order.id   <   order_detail.order_id   ');
194
195        self::assertSame('order_detail.order_id', $plan->relations[0]->right->toString());
196    }
197
198    /**
199     * @return array<string, array{string, string}>
200     */
201    public static function providerMalformedPlans(): array
202    {
203        return [
204            'target without a column' => ['order.id < order_detail', "'.' after the table name"],
205            'unknown operator' => ['order.id !! order_detail.order_id', "one of '<', '>' or '-'"],
206            'missing operator' => ['order.id order_detail.order_id', "one of '<', '>' or '-'"],
207            'unclosed group' => ['order.id < [a.x, b.x', "',' or ']'"],
208            'unclosed composite' => ['order.(a, b < x.y', "',' or ')'"],
209            'trailing junk' => ['order.id < order_detail.order_id extra', 'the end of the relation'],
210            'arity mismatch' => ['order.(a, b) < order_detail.(c)', 'names 2 columns on one side'],
211        ];
212    }
213
214    #[Test]
215    public function testAGroupFollowedByAnotherRelationStillSplitsCorrectly(): void
216    {
217        $plan = (new PlanParser())->parse('a.id < [b.a_id, c.a_id], d.id < e.d_id');
218
219        self::assertCount(3, $plan->relations);
220        self::assertSame(['a', 'b', 'c', 'd', 'e'], $plan->tables);
221    }
222
223    #[Test]
224    public function testAnEmptyStatementBetweenTwoRelationsIsIgnored(): void
225    {
226        $plan = (new PlanParser())->parse('a.id < b.a_id,, c.id < d.c_id');
227
228        self::assertCount(2, $plan->relations);
229    }
230
231    #[Test]
232    public function testATableNameFollowedByRelationsKeepsThemAll(): void
233    {
234        $plan = (new PlanParser())->parse('audit_log, a.id < b.a_id');
235
236        self::assertCount(1, $plan->relations);
237        self::assertSame(['audit_log', 'a', 'b'], $plan->tables);
238    }
239
240    #[Test]
241    public function testWhitespaceInsideAGroupIsSkipped(): void
242    {
243        $plan = (new PlanParser())->parse('a.id < [ b.a_id , c.a_id ]');
244
245        self::assertCount(2, $plan->relations);
246        self::assertSame('c.a_id', $plan->relations[1]->right->toString());
247    }
248
249    #[Test]
250    public function testAStrayClosingBracketDoesNotStopLaterRelationsSplitting(): void
251    {
252        $plan = (new PlanParser())->parse('a.id < [b.a_id], c.id < d.c_id');
253
254        self::assertCount(2, $plan->relations);
255    }
256
257    #[Test]
258    public function testWhitespaceBetweenAnEndpointAndTheOperatorIsSkipped(): void
259    {
260        $plan = (new PlanParser())->parse('a.(x , y)   <   b.(p , q)');
261
262        self::assertSame(['x', 'y'], $plan->relations[0]->left->columns);
263        self::assertSame(['p', 'q'], $plan->relations[0]->right->columns);
264    }
265
266    #[Test]
267    public function testAStrayOpeningBracketDoesNotSwallowLaterRelations(): void
268    {
269        $plan = (new PlanParser())->parse('a.id < b.a_id; c.id < d.c_id');
270
271        self::assertCount(2, $plan->relations);
272    }
273
274}
275