packages/ztd-query-core/tests/Unit/Shadow/Mutation/UpsertExpressionTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Shadow\Mutation;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use stdClass;
12use ZtdQuery\Exception\UnsupportedSqlException;
13use ZtdQuery\Shadow\Mutation\Upsert\UpsertOperator;
14use ZtdQuery\Shadow\Mutation\UpsertColumnSource;
15use ZtdQuery\Shadow\Mutation\UpsertExpression;
16use ZtdQuery\Shadow\Mutation\UpsertExpressionKind;
17
18#[CoversClass(UpsertExpression::class)]
19#[CoversClass(UpsertColumnSource::class)]
20#[UsesClass(UnsupportedSqlException::class)]
21#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertColumn::class)]
22#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertComparison::class)]
23#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertNumber::class)]
24#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertTruth::class)]
25#[UsesClass(UpsertOperator::class)]
26#[UsesClass(\ZtdQuery\Shadow\Mutation\Upsert\UpsertLiteral::class)]
27final class UpsertExpressionTest extends TestCase
28{
29    public function testLiteralAnswersTheValueTheStatementWrote(): void
30    {
31        $expression = UpsertExpression::binary(
32            UpsertExpressionKind::Add,
33            UpsertExpression::column(UpsertColumnSource::Existing, 'quantity'),
34            UpsertExpression::binary(
35                UpsertExpressionKind::Multiply,
36                UpsertExpression::column(UpsertColumnSource::Incoming, 'quantity'),
37                UpsertExpression::literal(2),
38            ),
39        );
40
41        self::assertSame(11, $expression->evaluate(['quantity' => 5], ['quantity' => 3], 'items'));
42    }
43
44    /**
45     * @return iterable<string, array{UpsertExpressionKind, bool|float|int|string|null, bool|float|int|string|null, mixed}>
46     */
47    public static function providerBinary(): iterable
48    {
49        yield 'subtract' => [UpsertExpressionKind::Subtract, 8, 3, 5];
50        yield 'multiply' => [UpsertExpressionKind::Multiply, 8, 3, 24];
51        yield 'divide' => [UpsertExpressionKind::Divide, 8, 2, 4];
52        yield 'modulo' => [UpsertExpressionKind::Modulo, 8, 3, 2];
53        yield 'equal' => [UpsertExpressionKind::Equal, 8, 3, false];
54        yield 'equal boundary' => [UpsertExpressionKind::Equal, 8, 8, true];
55        yield 'not equal' => [UpsertExpressionKind::NotEqual, 8, 3, true];
56        yield 'not equal boundary' => [UpsertExpressionKind::NotEqual, 8, 8, false];
57        yield 'less' => [UpsertExpressionKind::Less, 3, 8, true];
58        yield 'less boundary' => [UpsertExpressionKind::Less, 8, 8, false];
59        yield 'less or equal' => [UpsertExpressionKind::LessOrEqual, 8, 3, false];
60        yield 'less or equal boundary' => [UpsertExpressionKind::LessOrEqual, 8, 8, true];
61        yield 'greater' => [UpsertExpressionKind::Greater, 8, 3, true];
62        yield 'greater boundary' => [UpsertExpressionKind::Greater, 8, 8, false];
63        yield 'greater or equal' => [UpsertExpressionKind::GreaterOrEqual, 3, 8, false];
64        yield 'greater or equal boundary' => [UpsertExpressionKind::GreaterOrEqual, 8, 8, true];
65        yield 'and left' => [UpsertExpressionKind::And, false, true, false];
66        yield 'and right' => [UpsertExpressionKind::And, true, false, false];
67        yield 'or left' => [UpsertExpressionKind::Or, true, false, true];
68        yield 'or right' => [UpsertExpressionKind::Or, false, true, true];
69    }
70
71    /**
72     * @param UpsertExpressionKind $kind
73     */
74    #[DataProvider('providerBinary')]
75    public function testBinaryAnswersWhatTheOperatorMakesOfItsTwoOperands(
76        UpsertExpressionKind $kind,
77        bool|float|int|string|null $left,
78        bool|float|int|string|null $right,
79        mixed $expected,
80    ): void {
81        $expression = UpsertExpression::binary(
82            $kind,
83            UpsertExpression::literal($left),
84            UpsertExpression::literal($right),
85        );
86
87        self::assertSame($expected, $expression->evaluate([], [], 'items'));
88    }
89
90    public function testMatchesIsTrueOnlyWhereTheExpressionDefinitelyHolds(): void
91    {
92        self::assertSame(
93            -5,
94            UpsertExpression::unary(UpsertExpressionKind::UnaryMinus, UpsertExpression::literal(5))
95                ->evaluate([], [], 'items'),
96        );
97        self::assertSame(
98            5,
99            UpsertExpression::unary(UpsertExpressionKind::UnaryPlus, UpsertExpression::literal('5'))
100                ->evaluate([], [], 'items'),
101        );
102        self::assertFalse(
103            UpsertExpression::unary(UpsertExpressionKind::Not, UpsertExpression::literal(true))
104                ->matches([], [], 'items'),
105        );
106        self::assertFalse(UpsertExpression::literal(null)->matches([], [], 'items'));
107    }
108
109    public function testEvaluatesNullNumericTextAndCaseInsensitiveColumnBoundaries(): void
110    {
111        self::assertSame(3.5, UpsertExpression::unary(
112            UpsertExpressionKind::UnaryPlus,
113            UpsertExpression::literal('3.5'),
114        )->evaluate([], [], 'items'));
115        self::assertNull(UpsertExpression::unary(
116            UpsertExpressionKind::UnaryMinus,
117            UpsertExpression::literal(null),
118        )->evaluate([], [], 'items'));
119        self::assertNull(UpsertExpression::unary(
120            UpsertExpressionKind::Not,
121            UpsertExpression::literal(null),
122        )->evaluate([], [], 'items'));
123        self::assertSame(
124            7,
125            UpsertExpression::column(UpsertColumnSource::Existing, 'QuAnTiTy')
126                ->evaluate(['quantity' => 7], [], 'items'),
127        );
128        self::assertSame(
129            9,
130            UpsertExpression::column(UpsertColumnSource::Incoming, 'quantity')
131                ->evaluate([], ['quantity' => 9], 'items'),
132        );
133        self::assertTrue(UpsertExpression::binary(
134            UpsertExpressionKind::Less,
135            UpsertExpression::literal('alpha'),
136            UpsertExpression::literal('beta'),
137        )->matches([], [], 'items'));
138        self::assertTrue(UpsertExpression::binary(
139            UpsertExpressionKind::Equal,
140            UpsertExpression::literal('2'),
141            UpsertExpression::literal(2),
142        )->matches([], [], 'items'));
143        self::assertFalse(UpsertExpression::binary(
144            UpsertExpressionKind::Equal,
145            UpsertExpression::literal(true),
146            UpsertExpression::literal(false),
147        )->matches([], [], 'items'));
148        self::assertTrue(UpsertExpression::binary(
149            UpsertExpressionKind::Greater,
150            UpsertExpression::literal(true),
151            UpsertExpression::literal(false),
152        )->matches([], [], 'items'));
153    }
154
155    /**
156     * @return iterable<string, array{UpsertExpressionKind}>
157     */
158    public static function providerArithmeticKinds(): iterable
159    {
160        yield 'add' => [UpsertExpressionKind::Add];
161        yield 'subtract' => [UpsertExpressionKind::Subtract];
162        yield 'multiply' => [UpsertExpressionKind::Multiply];
163        yield 'divide' => [UpsertExpressionKind::Divide];
164        yield 'modulo' => [UpsertExpressionKind::Modulo];
165    }
166
167    #[DataProvider('providerArithmeticKinds')]
168    public function testArithmeticIsNullWhereTheLeftOperandIsNull(UpsertExpressionKind $kind): void
169    {
170        self::assertNull(UpsertExpression::binary(
171            $kind,
172            UpsertExpression::literal(null),
173            UpsertExpression::literal(2),
174        )->evaluate([], [], 'items'));
175    }
176
177    #[DataProvider('providerArithmeticKinds')]
178    public function testArithmeticIsNullWhereTheRightOperandIsNull(UpsertExpressionKind $kind): void
179    {
180        self::assertNull(UpsertExpression::binary(
181            $kind,
182            UpsertExpression::literal(2),
183            UpsertExpression::literal(null),
184        )->evaluate([], [], 'items'));
185    }
186
187    public function testArithmeticFloatingPointBoundaries(): void
188    {
189        self::assertSame(3.5, UpsertExpression::binary(
190            UpsertExpressionKind::Add,
191            UpsertExpression::literal('1.5'),
192            UpsertExpression::literal(2.0),
193        )->evaluate([], [], 'items'));
194        self::assertSame(2.5, UpsertExpression::binary(
195            UpsertExpressionKind::Divide,
196            UpsertExpression::literal(5),
197            UpsertExpression::literal(2),
198        )->evaluate([], [], 'items'));
199    }
200
201    public function testThreeValuedBooleanBoundaries(): void
202    {
203        self::assertNull(UpsertExpression::binary(
204            UpsertExpressionKind::And,
205            UpsertExpression::literal(true),
206            UpsertExpression::literal(null),
207        )->evaluate([], [], 'items'));
208        self::assertFalse(UpsertExpression::binary(
209            UpsertExpressionKind::And,
210            UpsertExpression::literal(false),
211            UpsertExpression::literal(null),
212        )->evaluate([], [], 'items'));
213        self::assertNull(UpsertExpression::binary(
214            UpsertExpressionKind::Or,
215            UpsertExpression::literal(false),
216            UpsertExpression::literal(null),
217        )->evaluate([], [], 'items'));
218        self::assertTrue(UpsertExpression::binary(
219            UpsertExpressionKind::Or,
220            UpsertExpression::literal(true),
221            UpsertExpression::literal(null),
222        )->evaluate([], [], 'items'));
223        self::assertNull(UpsertExpression::binary(
224            UpsertExpressionKind::And,
225            UpsertExpression::literal(null),
226            UpsertExpression::literal(true),
227        )->evaluate([], [], 'items'));
228        self::assertNull(UpsertExpression::binary(
229            UpsertExpressionKind::And,
230            UpsertExpression::literal(null),
231            UpsertExpression::literal(null),
232        )->evaluate([], [], 'items'));
233        self::assertNull(UpsertExpression::binary(
234            UpsertExpressionKind::Or,
235            UpsertExpression::literal(null),
236            UpsertExpression::literal(false),
237        )->evaluate([], [], 'items'));
238        self::assertNull(UpsertExpression::binary(
239            UpsertExpressionKind::Or,
240            UpsertExpression::literal(null),
241            UpsertExpression::literal(null),
242        )->evaluate([], [], 'items'));
243    }
244
245    public function testComparisonReturnsNullWhenEitherOperandIsNull(): void
246    {
247        self::assertNull(UpsertExpression::binary(
248            UpsertExpressionKind::Equal,
249            UpsertExpression::literal(null),
250            UpsertExpression::literal(1),
251        )->evaluate([], [], 'items'));
252        self::assertNull(UpsertExpression::binary(
253            UpsertExpressionKind::Equal,
254            UpsertExpression::literal(1),
255            UpsertExpression::literal(null),
256        )->evaluate([], [], 'items'));
257    }
258
259    public function testOperandRefusesAColumnTheRowDoesNotCarry(): void
260    {
261        try {
262            UpsertExpression::column(UpsertColumnSource::Existing, 'missing')->evaluate([], [], 'items');
263            self::fail('Unknown UPSERT column must be rejected');
264        } catch (UnsupportedSqlException $exception) {
265            self::assertSame('unknown UPSERT column missing', $exception->getSql());
266        }
267    }
268
269    public function testRejectsDivisionByZero(): void
270    {
271        $this->expectException(UnsupportedSqlException::class);
272
273        UpsertExpression::binary(
274            UpsertExpressionKind::Divide,
275            UpsertExpression::literal(1),
276            UpsertExpression::literal(0),
277        )->evaluate([], [], 'items');
278    }
279
280    public function testAcceptsFloatingPointOneAsDivisor(): void
281    {
282        self::assertSame(8.0, UpsertExpression::binary(
283            UpsertExpressionKind::Divide,
284            UpsertExpression::literal(8),
285            UpsertExpression::literal(1.0),
286        )->evaluate([], [], 'items'));
287    }
288
289    public function testRejectsModuloByZero(): void
290    {
291        $this->expectException(UnsupportedSqlException::class);
292
293        UpsertExpression::binary(
294            UpsertExpressionKind::Modulo,
295            UpsertExpression::literal(1),
296            UpsertExpression::literal(0),
297        )->evaluate([], [], 'items');
298    }
299
300    public function testRejectsNonNumericArithmeticOperand(): void
301    {
302        $this->expectException(UnsupportedSqlException::class);
303
304        UpsertExpression::binary(
305            UpsertExpressionKind::Add,
306            UpsertExpression::literal('not numeric'),
307            UpsertExpression::literal(1),
308        )->evaluate([], [], 'items');
309    }
310
311    public function testRejectsNumericAndTextComparison(): void
312    {
313        try {
314            UpsertExpression::binary(
315                UpsertExpressionKind::Equal,
316                UpsertExpression::literal(1),
317                UpsertExpression::literal('text'),
318            )->evaluate([], [], 'items');
319            self::fail('Numeric and text operands must not be compared');
320        } catch (UnsupportedSqlException $exception) {
321            self::assertSame('incomparable UPSERT operands', $exception->getSql());
322        }
323    }
324    public function testColumnReadsTheSelectedRowAndColumn(): void
325    {
326        $expression = UpsertExpression::column(UpsertColumnSource::Incoming, 'name');
327
328        self::assertSame('after', $expression->evaluate(['name' => 'before'], ['name' => 'after'], 'users'));
329    }
330
331    public function testUnaryNegatesTheOperand(): void
332    {
333        $expression = UpsertExpression::unary(UpsertExpressionKind::UnaryMinus, UpsertExpression::literal(3));
334
335        self::assertSame(-3, $expression->evaluate([], [], 'users'));
336    }
337
338    public function testLiteralPreservesAnOpaqueCallerValue(): void
339    {
340        $value = ['payload' => new stdClass()];
341
342        self::assertSame($value, UpsertExpression::literal($value)->evaluate([], [], 'users'));
343    }
344
345}
346