diff --git a/README.rst b/README.rst index 3cc768c57..bfe4134fb 100644 --- a/README.rst +++ b/README.rst @@ -320,6 +320,28 @@ parameters into the call. $jm->map(...); +Constructor map +--------- +Using JsonMapper's ``$constructorMap`` property, you can override how classes +shall get instantiated: + +.. code:: php + + $jm = new JsonMapper(); + $jm->constructorMap[\DateTime::class] = function ($jvalue) { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $jvalue)) { + return new \DateTime($jvalue); + } else { + throw new \Exception('Invalid date pattern'); + } + }; + $jm->map(...); + +.. note:: + This can be used to map JSON datetime strings to ``DateTime`` objects when + `$bStrictObjectTypeChecking`__ is enabled. + + Nullables --------- JsonMapper throws an exception when a JSON property is ``null``, @@ -490,9 +512,6 @@ when configured to do so: $jm->bStrictObjectTypeChecking = false; $jm->map(...); -This can be used to automatically initialize DateTime objects -from date strings. - Disabling this strict object type checks may lead to problems, though: - When a class does not have a constructor or no constructor parameter, diff --git a/src/JsonMapper.php b/src/JsonMapper.php index e1b1d703a..b785c11e8 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -76,6 +76,13 @@ class JsonMapper */ public array $classMap = []; + /** + * Override constructors that JsonMapper uses to create objects. + * + * @var array + */ + public array $constructorMap = []; + /** * Callback used when an undefined property is found. * @@ -312,6 +319,7 @@ public function map($json, $object) // but only a flat type (i.e. string, int) if ($this->bStrictObjectTypeChecking && !is_subclass_of($type, \BackedEnum::class) + && !$this->getMappedConstructor($type) ) { throw new JsonMapper_Exception( 'JSON property "' . $key . '" must be an object, ' @@ -466,6 +474,7 @@ public function mapArray( $array[$key] = $jvalue; } else if ($this->bStrictObjectTypeChecking && !is_subclass_of($class, \BackedEnum::class) + && !$this->getMappedConstructor($class) ) { throw new JsonMapper_Exception( 'JSON property' @@ -707,13 +716,21 @@ protected function setProperty( protected function createInstance( string $class, bool $useParameter = false, mixed $jvalue = null ): object { + $constructor = $this->getMappedConstructor($class); + if ($useParameter) { if (is_subclass_of($class, \BackedEnum::class)) { return $class::from($jvalue); } - return new $class($jvalue); + return null === $constructor + ? new $class($jvalue) + : $constructor($jvalue); } else { + if ($constructor) { + return $constructor(); + } + $reflectClass = new ReflectionClass($class); $constructor = $reflectClass->getConstructor(); if (null === $constructor @@ -740,7 +757,7 @@ protected function getMappedType(?string $type, mixed $jvalue = null): ?string { if (isset($this->classMap[$type ?? ''])) { $target = $this->classMap[$type]; - } else if (is_string($type) && $type !== '' && $type[0] == '\\' + } else if (is_string($type) && $type !== '' && str_starts_with($type, '\\') && isset($this->classMap[substr($type, 1)]) ) { $target = $this->classMap[substr($type, 1)]; @@ -758,6 +775,29 @@ protected function getMappedType(?string $type, mixed $jvalue = null): ?string return $type; } + /** + * Get the mapped constructor for this class. + * Returns null if not mapped. + * + * Lets you override constructors via the $constructorMap property. + * + * @param $class Class name to map + * + * @return ?callable The mapped constructor + */ + protected function getMappedConstructor(string $class): ?callable + { + if (isset($this->constructorMap[$class])) { + return $this->constructorMap[$class]; + } else if ($class !== '' && str_starts_with($class, '\\') + && isset($this->constructorMap[substr($class, 1)]) + ) { + return $this->constructorMap[substr($class, 1)]; + } else { + return null; + } + } + /** * Checks if the given type is a "simple type" * diff --git a/tests/ConstructorMapTest.php b/tests/ConstructorMapTest.php new file mode 100644 index 000000000..53495a0d3 --- /dev/null +++ b/tests/ConstructorMapTest.php @@ -0,0 +1,95 @@ +constructorMap[\DateTime::class] = function ($jvalue) { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $jvalue)) { + return new \DateTime($jvalue); + } else { + throw new \Exception('Invalid date pattern'); + } + }; + $sn = $jm->map( + json_decode('{"datetime": "2026-06-05"}'), + new JsonMapperTest_Object() + ); + + $this->assertInstanceOf(DateTime::class, $sn->datetime); + $this->assertSame( + '2026-06-05', + $sn->datetime->format('Y-m-d') + ); + } + + public function testConstructorMapWithLeadingBackslash() + { + $jm = new JsonMapper(); + $jm->constructorMap['\\DateTime'] = function ($jvalue) { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $jvalue)) { + return new \DateTime($jvalue); + } else { + throw new \Exception('Invalid date pattern'); + } + }; + $sn = $jm->map( + json_decode('{"datetime": "2026-06-05"}'), + new JsonMapperTest_Object() + ); + + $this->assertInstanceOf(DateTime::class, $sn->datetime); + $this->assertSame( + '2026-06-05', + $sn->datetime->format('Y-m-d') + ); + } + + public function testConstructorMapError() + { + $jm = new JsonMapper(); + $jm->constructorMap[\DateTime::class] = function ($jvalue) { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $jvalue)) { + return new \DateTime($jvalue); + } else { + throw new \Exception('Invalid date pattern'); + } + }; + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid date pattern'); + + $jm->map( + json_decode('{"datetime": "05/06/2026"}'), + new JsonMapperTest_Object() + ); + } + + public function testConstructorMapWithClassNameBased() + { + $jm = new JsonMapper(); + $jm->constructorMap[JsonMapperTest_Simple::class] = function () { + $obj = new JsonMapperTest_Simple(); + $obj->str = 'initial'; + return $obj; + }; + $sn = $jm->map( + json_decode('{"pbool": true}'), + JsonMapperTest_Simple::class + ); + + $this->assertSame( + 'initial', + $sn->str + ); + } +} +?>