packages/ztd-query-mysql/tests/Unit/Shadow/Mutation/Upsert/MySqlUpsertExpressionParserTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Shadow\Mutation\Upsert;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use ZtdQuery\Exception\UnsupportedSqlException;
12use ZtdQuery\Platform\MySql\Shadow\Mutation\Upsert\MySqlUpsertExpressionParser;
13
14#[CoversClass(MySqlUpsertExpressionParser::class)]
15#[UsesClass(\ZtdQuery\Platform\MySql\Sql\MySqlLexerProfile::class)]
16#[CoversClass(\ZtdQuery\Platform\MySql\Shadow\Mutation\Upsert\ExpressionReader::class)]
17#[CoversClass(\ZtdQuery\Platform\MySql\Shadow\Mutation\Upsert\LiteralReader::class)]
18#[CoversClass(\ZtdQuery\Platform\MySql\Shadow\Mutation\Upsert\StringLiteral::class)]
19final class MySqlUpsertExpressionParserTest extends TestCase
20{
21 #[DataProvider('providerMySqlExpressionCases')]
22 public function testParsesMySqlExpressionCases(string $sql, mixed $expected): void
23 {
24 self::assertSame(
25 $expected,
26 (new MySqlUpsertExpressionParser())->parse($sql, 'items')->evaluate([], [], 'items'),
27 );
28 }
29
30 /**
31
32 * @return iterable<string, array{string, mixed}>
33
34 */
35 public static function providerMySqlExpressionCases(): iterable
36 {
37 yield 'chained or' => ['1 OR 0 OR 0', true];
38 yield 'chained and' => ['1 AND 1 AND 0', false];
39 yield 'equal' => ['1 = 2', false];
40 yield 'bang not equal' => ['1 != 2', true];
41 yield 'angle not equal' => ['1 <> 2', true];
42 yield 'less' => ['1 < 2', true];
43 yield 'less or equal' => ['1 <= 2', true];
44 yield 'greater' => ['2 > 1', true];
45 yield 'greater or equal' => ['2 >= 1', true];
46 yield 'chained additive' => ['10 - 3 + 2', 9];
47 yield 'chained multiplicative' => ['20 / 5 % 3 * 2', 2];
48 yield 'nested unary minus' => ['- -2', 2];
49 yield 'nested unary plus' => ['+ +2', 2];
50 yield 'unary minus' => ['-2', -2];
51 yield 'unary plus' => ['+2', 2];
52 yield 'nested not' => ['NOT NOT TRUE', true];
53 yield 'parenthesized precedence' => ['(1 + 2) * 3', 9];
54 yield 'null' => ['NULL', null];
55 yield 'false' => ['FALSE', false];
56 yield 'hex integer' => ['0x10', 16];
57 yield 'exponent' => ['1.5e1', 15.0];
58 yield 'escaped string' => ["'it''s'", "it's"];
59 }
60
61 public function testParsesValuesAndExistingTableReferences(): void
62 {
63 $expression = (new MySqlUpsertExpressionParser())->parse(
64 'items.quantity + VALUES(`quantity`) * 2',
65 'items',
66 );
67
68 self::assertSame(11, $expression->evaluate(['quantity' => 5], ['quantity' => 3], 'items'));
69 }
70
71 public function testParsesMySqlIncomingRowAlias(): void
72 {
73 $expression = (new MySqlUpsertExpressionParser())->parse('new_row.quantity + 1', 'items', 'new_row');
74
75 self::assertSame(4, $expression->evaluate(['quantity' => 5], ['quantity' => 3], 'items'));
76 }
77
78 public function testUnescapesQuotedIdentifiersAndMySqlStrings(): void
79 {
80 $expression = (new MySqlUpsertExpressionParser())->parse(
81 '`it``ems`.`quan``tity` + VALUES(`quan``tity`)',
82 'it`ems',
83 );
84
85 self::assertSame(8, $expression->evaluate(['quan`tity' => 5], ['quan`tity' => 3], 'it`ems'));
86 self::assertSame(
87 "a'b\\c",
88 (new MySqlUpsertExpressionParser())->parse("'a\\'b\\\\c'", 'items')->evaluate([], [], 'items'),
89 );
90 }
91
92 public function testParsesLiteralsPredicatesAndUnaryOperators(): void
93 {
94 $expression = (new MySqlUpsertExpressionParser())->parse(
95 "NOT (score >= 80 AND VALUES(name) <> 'blocked')",
96 'items',
97 );
98
99 self::assertTrue($expression->matches(['score' => 70], ['name' => 'ready'], 'items'));
100 }
101
102 public function testParseIfSupportedReturnsNullForUnsupportedFunction(): void
103 {
104 self::assertNull((new MySqlUpsertExpressionParser())->parseIfSupported('COALESCE(score, 0)', 'items'));
105 }
106
107 public function testRejectsPostgreSqlIncomingQualifier(): void
108 {
109 $this->expectException(UnsupportedSqlException::class);
110
111 (new MySqlUpsertExpressionParser())->parse('EXCLUDED.quantity', 'items');
112 }
113
114 #[DataProvider('providerInvalidMySqlExpression')]
115 public function testRejectsInvalidMySqlExpression(string $sql): void
116 {
117 self::assertNull((new MySqlUpsertExpressionParser())->parseIfSupported($sql, 'items'));
118 }
119
120 /**
121
122 * @return iterable<string, array{string}>
123
124 */
125 public static function providerInvalidMySqlExpression(): iterable
126 {
127 yield 'empty' => [''];
128 yield 'missing close parenthesis' => ['(1 + 2'];
129 yield 'wrong close parenthesis' => ['(1 + 2 value'];
130 yield 'extra close parenthesis' => ['1 + 2)'];
131 yield 'missing additive operand' => ['1 +'];
132 yield 'missing comparison operand' => ['1 ='];
133 yield 'missing qualified column' => ['items.'];
134 yield 'invalid qualified column' => ['items.+'];
135 yield 'invalid comparison pair' => ['1 ! 2'];
136 yield 'double equals' => ['1 == 1'];
137 yield 'numeric underscore' => ['1_000'];
138 yield 'uppercase hex prefix' => ['0X10'];
139 yield 'unknown qualifier' => ['other.value'];
140 yield 'empty values' => ['VALUES()'];
141 yield 'values without parenthesis' => ['VALUES + 1'];
142 yield 'values with wrong opening token' => ['VALUES ignored quantity )'];
143 yield 'unclosed values' => ['VALUES(value'];
144 yield 'values with wrong closing token' => ['VALUES(value extra'];
145 yield 'non-identifier values' => ['VALUES(1)'];
146 }
147}
148