Skip to content

Commit

Permalink
Merge pull request #8 from andreypostal/key-mapping-strategy
Browse files Browse the repository at this point in the history
Rework trait usage and add key mapping strategy
  • Loading branch information
andreypostal authored Feb 4, 2025
2 parents c9acdc5 + 2f04b88 commit dac530a
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 55 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ composer.lock
.hg
.phpunit.result.cache
.phpunit.cache
temp

# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
Expand Down
8 changes: 8 additions & 0 deletions src/HydratorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Andrey\JsonHandler;

interface HydratorInterface
{
public function hydrate(string|array $json, object|string $objOrClass): object;
}
27 changes: 0 additions & 27 deletions src/JsonHandler.php

This file was deleted.

53 changes: 27 additions & 26 deletions src/JsonHydratorTrait.php → src/JsonHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,56 +3,49 @@

use Andrey\JsonHandler\Attributes\JsonItemAttribute;
use Andrey\JsonHandler\Attributes\JsonObjectAttribute;
use Andrey\JsonHandler\KeyMapping\KeyMappingStrategy;
use Andrey\JsonHandler\KeyMapping\KeyMappingUnderscore;
use InvalidArgumentException;
use JsonException;
use LogicException;
use ReflectionClass;
use ReflectionException;
use ReflectionProperty;

trait JsonHydratorTrait
readonly class JsonHydrator implements HydratorInterface
{
/**
* @throws JsonException
* @throws ReflectionException
*/
public function hydrateObjectImmutable(string|array $json, object $obj): object
{
return $this->hydrateObject($json, clone $obj);
private KeyMappingStrategy $keyStrategy;

public function __construct(
?KeyMappingStrategy $keyStrategy = null,
) {
$this->keyStrategy = $keyStrategy ?: new KeyMappingUnderscore();
}

/**
* @throws JsonException
* @throws ReflectionException
*/
public function hydrateObject(string|array $json, object $obj): object
public function hydrate(string|array $json, object|string $objOrClass): object
{
$jsonArr = is_string($json) ? JsonHandler::Decode($json) : $json;
$reflectionClass = new ReflectionClass($obj);
$data = $this->processClass($reflectionClass, $jsonArr);
if ($reflectionClass->hasMethod('hydrate')) {
$obj->hydrate($data);
} else {
foreach ($data as $key => $value) {
$obj->{$key} = $value;
}
}
return $obj;
$jsonArr = is_string($json) ? $this->decode($json) : $json;
$reflectionClass = new ReflectionClass($objOrClass);
return $this->processClass($reflectionClass, $jsonArr);
}

/**
* @throws JsonException
* @throws ReflectionException
*/
private function processClass(ReflectionClass $class, array $jsonArr): array
private function processClass(ReflectionClass $class, array $jsonArr): object
{
$instance = $class->newInstance();
$skipAttributeCheck = ($class->getAttributes(JsonObjectAttribute::class)[0] ?? null) !== null;
$output = [];
$properties = $class->getProperties();
foreach ($properties as $property) {
$output[$property->getName()] = $this->processProperty($property, $jsonArr, $skipAttributeCheck);
$property->setValue($instance, $this->processProperty($property, $jsonArr, $skipAttributeCheck));
}
return $output;
return $instance;
}

/**
Expand All @@ -69,7 +62,7 @@ private function processProperty(ReflectionProperty $property, array $jsonArr, b

/** @var JsonItemAttribute $item */
$item = $attr?->newInstance() ?? new JsonItemAttribute();
$key = $item->key ?? $property->getName();
$key = $item->key ?? $this->keyStrategy->from($property->getName());
if ($item->required && !array_key_exists($key, $jsonArr)) {
throw new InvalidArgumentException(sprintf('required item <%s> not found', $key));
}
Expand Down Expand Up @@ -114,9 +107,17 @@ private function handleCustomType(mixed $value, string $type): mixed
if ($typeReflection->isEnum()) {
return call_user_func($type.'::tryFrom', $value);
}
return $this->hydrateObject(
return $this->hydrate(
$value,
new ($type)(),
);
}

/**
* @throws JsonException
*/
private function decode(string $json): mixed
{
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
}
14 changes: 12 additions & 2 deletions src/JsonSerializerTrait.php → src/JsonSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@

use Andrey\JsonHandler\Attributes\JsonItemAttribute;
use Andrey\JsonHandler\Attributes\JsonObjectAttribute;
use Andrey\JsonHandler\KeyMapping\KeyMappingStrategy;
use Andrey\JsonHandler\KeyMapping\KeyMappingUnderscore;
use JsonException;
use ReflectionClass;
use ReflectionException;

trait JsonSerializerTrait
readonly class JsonSerializer implements SerializerInterface
{
private KeyMappingStrategy $keyStrategy;

public function __construct(
?KeyMappingStrategy $keyStrategy = null,
) {
$this->keyStrategy = $keyStrategy ?: new KeyMappingUnderscore();
}

/**
* @throws ReflectionException
* @throws JsonException
Expand All @@ -28,7 +38,7 @@ public function serialize(object $obj): array
}
/** @var JsonItemAttribute $item */
$item = $attr?->newInstance() ?? new JsonItemAttribute();
$key = $item->key ?? $property->name;
$key = $item->key ?? $this->keyStrategy->from($property->name);

if ($property->getType()?->isBuiltin()) {
$output[$key] = $this->handleArray($item, $property->getValue($obj));
Expand Down
11 changes: 11 additions & 0 deletions src/KeyMapping/KeyMappingStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Andrey\JsonHandler\KeyMapping;

interface KeyMappingStrategy
{
// -> Hydrate
public function from(string $key): string;
// <- Parse
public function to(string $key): string;
}
60 changes: 60 additions & 0 deletions src/KeyMapping/KeyMappingUnderscore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php /** @noinspection ForeachInvariantsInspection */

namespace Andrey\JsonHandler\KeyMapping;

/**
*
*/
class KeyMappingUnderscore implements KeyMappingStrategy
{
/**
* Map keys from pascalCase to underscore_case
*
*/
public function from(string $key): string
{
$in = str_split($key);
$len = count($in);
$out = '';
for ($i = 0; $i < $len; $i++) {
if ($in[$i] < 'a') {
$out .= '_';
// if already is an underscore, just skip case conversion
// but still add another underscore before it
if ($in[$i] !== '_') {
$out .= chr((ord($in[$i]) - ord('A')) + ord('a'));
} else {
$out .= $in[$i];
}
} else {
$out .= $in[$i];
}
}
return $out;
}

public function to(string $key): string
{
$in = str_split($key);
$len = count($in);
$out = '';
// my_key => myKey
for ($i = 0; $i < $len; $i++) {
$c = $in[$i];
if ($c === '_') {
$c = $in[$i+1];
// jump to next letter (skip lowercase already dealt with)
$i++;
if ($c !== '_') {
$out .= chr((ord($c) - ord('a')) + ord('A'));
} else {
$out .= $c;
}
} else {
$out .= $c;
}
}

return $out;
}
}
8 changes: 8 additions & 0 deletions src/SerializerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Andrey\JsonHandler;

interface SerializerInterface
{
public function serialize(object $obj): array;
}
58 changes: 58 additions & 0 deletions tests/KeyMapping/KeyMappingUnderscoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
namespace KeyMapping;

use Andrey\JsonHandler\KeyMapping\KeyMappingUnderscore;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\TestCase;

#[CoversTrait(KeyMappingUnderscore::class)]
final class KeyMappingUnderscoreTest extends TestCase
{
public function testFromKey(): void
{
$strategy = new KeyMappingUnderscore();
$result = $strategy->from('fromMyKey');
$this->assertEquals('from_my_key', $result);
}

public function testFromKeyStartingWithUnderscore(): void
{
$strategy = new KeyMappingUnderscore();
$result = $strategy->from('_fromMyKey');
$this->assertEquals('__from_my_key', $result);
}

/**
* For pascal case maintain behavior otherwise we cannot keep
* the equality (parser -> serializer and serializer -> parser)
*
* i.e. _from_my_key => FromMyKey but from_my_key => fromMyKey
*/
public function testFromKeyPascalCase(): void
{
$strategy = new KeyMappingUnderscore();
$result = $strategy->from('FromMyKey');
$this->assertEquals('_from_my_key', $result);
}

public function testToKey(): void
{
$strategy = new KeyMappingUnderscore();
$result = $strategy->to('from_my_key');
$this->assertEquals('fromMyKey', $result);
}

public function testToKeyStartingWithUnderscore(): void
{
$strategy = new KeyMappingUnderscore();
$result = $strategy->to('__from_my_key');
$this->assertEquals('_fromMyKey', $result);
}

public function testToKeyPascalCase(): void
{
$strategy = new KeyMappingUnderscore();
$result = $strategy->to('_from_my_key');
$this->assertEquals('FromMyKey', $result);
}
}

0 comments on commit dac530a

Please sign in to comment.