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