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