packages/sql-fixture/tests/Unit/Hydrator/ReflectionHydratorTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Hydrator;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\Test;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use ReflectionException;
12use SqlFixture\Hydrator\HydrationException;
13use SqlFixture\Hydrator\ReflectionHydrator;
14
15#[CoversClass(ReflectionHydrator::class)]
16#[UsesClass(HydrationException::class)]
17#[CoversClass(\SqlFixture\Hydrator\Reflection\ConstructorHydration::class)]
18#[CoversClass(\SqlFixture\Hydrator\Reflection\PropertyHydration::class)]
19#[CoversClass(\SqlFixture\Hydrator\Reflection\PropertyNames::class)]
20#[CoversClass(\SqlFixture\Hydrator\Reflection\ValueConversion::class)]
21#[UsesClass(\SqlFixture\Hydrator\HydratorInterface::class)]
22#[UsesClass(\SqlFixture\Hydrator\Exception\ClassNotFoundException::class)]
23#[UsesClass(\SqlFixture\Hydrator\Exception\MissingConstructorArgumentException::class)]
24final class ReflectionHydratorTest extends TestCase
25{
26 /**
27 * @throws ReflectionException
28 */
29 #[Test]
30 public function testHydrateViaConstructor(): void
31 {
32 $target = new class (0, '') {
33 public function __construct(
34 public readonly int $id,
35 public readonly string $name,
36 ) {
37 }
38 };
39 $data = ['id' => 1, 'name' => 'Test'];
40 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
41
42 self::assertSame(1, $object->id);
43 self::assertSame('Test', $object->name);
44 }
45
46 /**
47 * @throws ReflectionException
48 */
49 #[Test]
50 public function testHydrateViaProperties(): void
51 {
52 $target = new class () {
53 public int $id = 0;
54 public string $name = '';
55 };
56 $data = ['id' => 1, 'name' => 'Test'];
57 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
58
59 self::assertSame(1, $object->id);
60 self::assertSame('Test', $object->name);
61 }
62
63 /**
64 * @throws ReflectionException
65 */
66 #[Test]
67 public function testHydrateWithSnakeCaseToConstructor(): void
68 {
69 $target = new class (0, '') {
70 public function __construct(
71 public readonly int $userId,
72 public readonly string $fullName,
73 ) {
74 }
75 };
76 $data = ['user_id' => 42, 'full_name' => 'John Doe'];
77 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
78
79 self::assertSame(42, $object->userId);
80 self::assertSame('John Doe', $object->fullName);
81 }
82
83 /**
84 * @throws ReflectionException
85 */
86 #[Test]
87 public function testHydrateWithDefaultValues(): void
88 {
89 $target = new class (0) {
90 public function __construct(
91 public readonly int $id,
92 public readonly string $name = 'default',
93 ) {
94 }
95 };
96 $data = ['id' => 1];
97 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
98
99 self::assertSame(1, $object->id);
100 self::assertSame('default', $object->name);
101 }
102
103 /**
104 * @throws ReflectionException
105 */
106 #[Test]
107 public function testHydrateWithNullableParameter(): void
108 {
109 $target = new class (0) {
110 public function __construct(
111 public readonly int $id,
112 public readonly ?string $name = null,
113 ) {
114 }
115 };
116 $data = ['id' => 1];
117 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
118
119 self::assertSame(1, $object->id);
120 self::assertNull($object->name);
121 }
122
123 /**
124 * @throws ReflectionException
125 */
126 #[Test]
127 public function testThrowsExceptionForMissingRequiredParameter(): void
128 {
129 $target = new class (0, '') {
130 public function __construct(
131 public readonly int $id,
132 public readonly string $name,
133 ) {
134 }
135 };
136 $this->expectException(\SqlFixture\Hydrator\Exception\MissingConstructorArgumentException::class);
137 (new ReflectionHydrator())->hydrate(['name' => 'Test'], $target::class);
138 }
139
140 /**
141 * @throws ReflectionException
142 */
143 #[Test]
144 public function testThrowsExceptionForNonExistentClass(): void
145 {
146 $hydrator = new ReflectionHydrator();
147 self::expectException(\SqlFixture\Hydrator\Exception\ClassNotFoundException::class);
148 /**
149 * @var class-string<object> $class
150 */
151 $class = 'NonExistentClass';
152 $hydrator->hydrate([], $class);
153 }
154
155 /**
156 * @throws ReflectionException
157 */
158 #[Test]
159 public function testCastsIntValue(): void
160 {
161 $target = new class (0, '') {
162 public function __construct(
163 public readonly int $id,
164 public readonly string $name,
165 ) {
166 }
167 };
168 $data = ['id' => '42', 'name' => 'Test'];
169 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
170
171 self::assertSame(42, $object->id);
172 self::assertIsInt($object->id);
173 self::assertNotSame('42', $object->id);
174 }
175
176 /**
177 * @throws ReflectionException
178 */
179 #[Test]
180 public function testCastsFloatValue(): void
181 {
182 $target = new class (0.0) {
183 public function __construct(
184 public readonly float $amount,
185 ) {
186 }
187 };
188 $data = ['amount' => '99.99'];
189 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
190
191 self::assertSame(99.99, $object->amount);
192 self::assertIsFloat($object->amount);
193 self::assertNotSame('99.99', $object->amount);
194 }
195
196 /**
197 * @throws ReflectionException
198 */
199 #[Test]
200 public function testCastsBoolValue(): void
201 {
202 $target = new class (false) {
203 public function __construct(
204 public readonly bool $active,
205 ) {
206 }
207 };
208 $data = ['active' => 1];
209 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
210
211 self::assertTrue($object->active);
212 self::assertIsBool($object->active);
213 self::assertNotSame(1, $object->active);
214 }
215
216 /**
217 * @throws ReflectionException
218 */
219 #[Test]
220 public function testCastsBoolFalseValue(): void
221 {
222 $target = new class (false) {
223 public function __construct(
224 public readonly bool $active,
225 ) {
226 }
227 };
228 $data = ['active' => 0];
229 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
230
231 self::assertFalse($object->active);
232 self::assertIsBool($object->active);
233 }
234
235 /**
236 * @throws ReflectionException
237 */
238 #[Test]
239 public function testCastsArrayValue(): void
240 {
241 $target = new class ([]) {
242 /**
243 * @param list<string> $items
244 */
245 public function __construct(
246 public readonly array $items,
247 ) {
248 }
249 };
250 $data = ['items' => '["a","b","c"]'];
251 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
252
253 self::assertSame(['a', 'b', 'c'], $object->items);
254 self::assertIsArray($object->items);
255 }
256
257 /**
258 * @throws ReflectionException
259 */
260 #[Test]
261 public function testCastsArrayFromNonJsonString(): void
262 {
263 $target = new class ([]) {
264 /**
265 * @param list<string> $items
266 */
267 public function __construct(
268 public readonly array $items,
269 ) {
270 }
271 };
272 $data = ['items' => 'single_value'];
273 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
274
275 self::assertSame(['single_value'], $object->items);
276 self::assertIsArray($object->items);
277 }
278
279 /**
280 * @throws ReflectionException
281 */
282 #[Test]
283 public function testCastsStringValue(): void
284 {
285 $target = new class ('') {
286 public function __construct(
287 public readonly string $value,
288 ) {
289 }
290 };
291 $data = ['value' => 123];
292 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
293
294 self::assertSame('123', $object->value);
295 self::assertIsString($object->value);
296 self::assertNotSame(123, $object->value);
297 }
298
299 /**
300 * @throws ReflectionException
301 */
302 #[Test]
303 public function testHydrateWithSnakeCaseProperties(): void
304 {
305 $target = new class () {
306 public function __construct(
307 public string $userName = '',
308 ) {
309 }
310 };
311 $data = ['user_name' => 'John'];
312 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
313
314 self::assertSame('John', $object->userName);
315 }
316
317 /**
318 * @throws ReflectionException
319 */
320 #[Test]
321 public function testHydrateIgnoresExtraData(): void
322 {
323 $target = new class (0, '') {
324 public function __construct(
325 public readonly int $id,
326 public readonly string $name,
327 ) {
328 }
329 };
330 $data = ['id' => 1, 'name' => 'Test', 'extra' => 'ignored'];
331 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
332
333 self::assertSame(1, $object->id);
334 self::assertSame('Test', $object->name);
335 }
336
337 /**
338 * @throws ReflectionException
339 */
340 #[Test]
341 public function testHydrateWithMixedType(): void
342 {
343 $target = new class (null) {
344 /**
345 * @param array{nested: string}|null $value
346 */
347 public function __construct(
348 public readonly mixed $value,
349 ) {
350 }
351 };
352 $data = ['value' => ['nested' => 'data']];
353 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
354
355 self::assertSame(['nested' => 'data'], $object->value);
356 }
357
358 /**
359 * @throws ReflectionException
360 */
361 #[Test]
362 public function testHydrateViaPropertiesWithCasting(): void
363 {
364 $target = new class () {
365 public int $id = 0;
366 public string $name = '';
367 public float $amount = 0.0;
368 public bool $active = false;
369 };
370 $data = ['id' => '42', 'name' => 123, 'amount' => '9.5', 'active' => 1];
371 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
372
373 self::assertSame(42, $object->id);
374 self::assertSame('123', $object->name);
375 self::assertSame(9.5, $object->amount);
376 self::assertTrue($object->active);
377 }
378
379 /**
380 * @throws ReflectionException
381 */
382 #[Test]
383 public function testHydrateViaPropertiesIgnoresUnknownKeys(): void
384 {
385 $target = new class () {
386 public int $id = 0;
387 public string $name = '';
388 public float $amount = 0.0;
389 public bool $active = false;
390 };
391 $data = ['unknown_key' => 'value'];
392 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
393
394 self::assertSame(0, $object->id);
395 }
396
397 /**
398 * @throws ReflectionException
399 */
400 #[Test]
401 public function testHydrateViaPropertiesSkipsUnknownAndContinues(): void
402 {
403 $target = new class () {
404 public int $id = 0;
405 public string $name = '';
406 public float $amount = 0.0;
407 public bool $active = false;
408 };
409 $data = ['unknown_key' => 'value', 'id' => 42, 'name' => 'Test'];
410 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
411
412 self::assertSame(42, $object->id);
413 self::assertSame('Test', $object->name);
414 }
415
416 /**
417 * @throws ReflectionException
418 */
419 #[Test]
420 public function testHydrateViaPropertiesWithSnakeCase(): void
421 {
422 $target = new class () {
423 public string $userName = '';
424 };
425 $data = ['user_name' => 'John'];
426 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
427
428 self::assertSame('John', $object->userName);
429 }
430
431 /**
432 * @throws ReflectionException
433 */
434 #[Test]
435 public function testHydrateViaPropertiesDirectKey(): void
436 {
437 $target = new class () {
438 public string $userName = '';
439 };
440 $data = ['userName' => 'Direct'];
441 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
442
443 self::assertSame('Direct', $object->userName);
444 }
445
446 /**
447 * @throws ReflectionException
448 */
449 #[Test]
450 public function testHydrateViaConstructorWithEmptyConstructor(): void
451 {
452 $target = new class () {
453 public int $id = 0;
454 public string $name = '';
455
456 public function __construct()
457 {
458 }
459 };
460 $data = ['id' => 5, 'name' => 'Test'];
461 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
462
463 self::assertSame(5, $object->id);
464 self::assertSame('Test', $object->name);
465 }
466
467 /**
468 * @throws ReflectionException
469 */
470 #[Test]
471 public function testCastNullReturnsNull(): void
472 {
473 $target = new class (0) {
474 public function __construct(
475 public readonly int $id,
476 public readonly ?string $name = null,
477 ) {
478 }
479 };
480 $data = ['id' => 1, 'name' => null];
481 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
482
483 self::assertNull($object->name);
484 }
485
486 /**
487 * @throws ReflectionException
488 */
489 #[Test]
490 public function testCastArrayFromAlreadyArray(): void
491 {
492 $target = new class ([]) {
493 /**
494 * @param list<string> $items
495 */
496 public function __construct(
497 public readonly array $items,
498 ) {
499 }
500 };
501 $data = ['items' => ['existing', 'array']];
502 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
503
504 self::assertSame(['existing', 'array'], $object->items);
505 }
506
507 /**
508 * @throws ReflectionException
509 */
510 #[Test]
511 public function testHydratePreservesStringInMixedIdProperty(): void
512 {
513 $target = new class () {
514 /**
515 * @var string|null
516 */
517 public mixed $id = null;
518 };
519 $data = ['id' => 'not_a_number'];
520 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
521
522 self::assertSame('not_a_number', $object->id);
523 }
524
525 /**
526 * @throws ReflectionException
527 */
528 #[Test]
529 public function testHydratePreservesStringInMixedAmountProperty(): void
530 {
531 $target = new class () {
532 /**
533 * @var string|null
534 */
535 public mixed $amount = null;
536 };
537 $data = ['amount' => 'not_a_number'];
538 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
539
540 self::assertSame('not_a_number', $object->amount);
541 }
542
543 /**
544 * @throws ReflectionException
545 */
546 #[Test]
547 public function testHydratePreservesArrayInMixedNameProperty(): void
548 {
549 $target = new class () {
550 /**
551 * @var list<string>|null
552 */
553 public mixed $name = null;
554 };
555 $data = ['name' => ['array_value']];
556 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
557
558 self::assertSame(['array_value'], $object->name);
559 }
560
561 /**
562 * @throws ReflectionException
563 */
564 #[Test]
565 public function testCastIntToArrayViaCastValue(): void
566 {
567 $target = new class ([]) {
568 /**
569 * @param list<int> $items
570 */
571 public function __construct(
572 public readonly array $items,
573 ) {
574 }
575 };
576 $data = ['items' => 42];
577 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
578
579 self::assertSame([42], $object->items);
580 }
581
582 /**
583 * @throws ReflectionException
584 */
585 #[Test]
586 public function testCastBoolToArrayViaCastValue(): void
587 {
588 $target = new class ([]) {
589 /**
590 * @param list<bool> $items
591 */
592 public function __construct(
593 public readonly array $items,
594 ) {
595 }
596 };
597 $data = ['items' => true];
598 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
599
600 self::assertSame([true], $object->items);
601 }
602
603 /**
604 * @throws ReflectionException
605 */
606 #[Test]
607 public function testCastsIntValueFromNumericString(): void
608 {
609 $target = new class (0, '') {
610 public function __construct(
611 public readonly int $id,
612 public readonly string $name,
613 ) {
614 }
615 };
616 $data = ['id' => '123', 'name' => 'Test'];
617 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
618
619 self::assertSame(123, $object->id);
620 }
621
622 /**
623 * @throws ReflectionException
624 */
625 #[Test]
626 public function testCastsFloatValueFromNumericString(): void
627 {
628 $target = new class (0.0) {
629 public function __construct(
630 public readonly float $amount,
631 ) {
632 }
633 };
634 $data = ['amount' => '3.14'];
635 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
636
637 self::assertSame(3.14, $object->amount);
638 }
639
640 /**
641 * @throws ReflectionException
642 */
643 #[Test]
644 public function testCastsStringFromIntValue(): void
645 {
646 $target = new class ('') {
647 public function __construct(
648 public readonly string $value,
649 ) {
650 }
651 };
652 $data = ['value' => 42];
653 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
654
655 self::assertSame('42', $object->value);
656 }
657
658 /**
659 * @throws ReflectionException
660 */
661 #[Test]
662 public function testCastsBoolFromTruthyValue(): void
663 {
664 $target = new class (false) {
665 public function __construct(
666 public readonly bool $active,
667 ) {
668 }
669 };
670 $data = ['active' => 'yes'];
671 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
672
673 self::assertTrue($object->active);
674 }
675
676 /**
677 * @throws ReflectionException
678 */
679 #[Test]
680 public function testCastsBoolFromFalsyValue(): void
681 {
682 $target = new class (false) {
683 public function __construct(
684 public readonly bool $active,
685 ) {
686 }
687 };
688 $data = ['active' => ''];
689 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
690
691 self::assertFalse($object->active);
692 }
693
694 /**
695 * @throws ReflectionException
696 */
697 #[Test]
698 public function testCastIntFromFloat(): void
699 {
700 $target = new class (0, '') {
701 public function __construct(
702 public readonly int $id,
703 public readonly string $name,
704 ) {
705 }
706 };
707 $data = ['id' => 7.9, 'name' => 'Test'];
708 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
709
710 self::assertSame(7, $object->id);
711 }
712
713 /**
714 * @throws ReflectionException
715 */
716 #[Test]
717 public function testCastFloatFromInt(): void
718 {
719 $target = new class (0.0) {
720 public function __construct(
721 public readonly float $amount,
722 ) {
723 }
724 };
725 $data = ['amount' => 10];
726 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
727
728 self::assertSame(10.0, $object->amount);
729 }
730
731 /**
732 * @throws ReflectionException
733 */
734 #[Test]
735 public function testCastStringFromBool(): void
736 {
737 $target = new class ('') {
738 public function __construct(
739 public readonly string $value,
740 ) {
741 }
742 };
743 $data = ['value' => true];
744 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
745
746 self::assertSame('1', $object->value);
747 }
748
749 /**
750 * @throws ReflectionException
751 */
752 #[Test]
753 public function testCastStringFromFloat(): void
754 {
755 $target = new class ('') {
756 public function __construct(
757 public readonly string $value,
758 ) {
759 }
760 };
761 $data = ['value' => 3.14];
762 $object = (new ReflectionHydrator())->hydrate($data, $target::class);
763
764 self::assertSame('3.14', $object->value);
765 }
766}
767