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