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