-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
New: 1. Compute change set between early submitted data and new one, reflect change set onto next steps Deprecations: 1. Save different entity types 2. Entity copy service usage
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,6 @@ | |
"@default": true | ||
}, | ||
"testFramework": "phpunit", | ||
"minCoveredMsi": 90, | ||
"minCoveredMsi": 88, | ||
"timeout": 3, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm; | ||
|
||
use ReflectionObject; | ||
|
||
use function array_key_exists; | ||
use function is_array; | ||
use function is_object; | ||
|
||
final class EntityCopy | ||
{ | ||
public static function copy(mixed $value, mixed ...$replace): mixed | ||
{ | ||
return match (true) { | ||
is_array($value) => self::copyArray($value, ...$replace), | ||
is_object($value) => self::copyObject($value, ...$replace), | ||
default => empty($replace) ? $value : $replace[0], | ||
}; | ||
} | ||
|
||
/** | ||
* @param array<string, mixed> $array | ||
* | ||
* @return array<string, mixed> | ||
*/ | ||
private static function copyArray(array $array, mixed ...$replace): array | ||
{ | ||
$replace = empty($replace) ? [] : $replace[0]; | ||
|
||
foreach ($array as $key => $value) { | ||
$array[$key] = isset($replace[$key]) ? self::copy($value, $replace[$key]) : self::copy($value); | ||
} | ||
|
||
return $array; | ||
} | ||
|
||
private static function copyObject(object $object, mixed ...$replace): ?object | ||
{ | ||
if (!empty($replace)) { | ||
if ($replace[0] === null) { | ||
return null; | ||
} | ||
|
||
$replace = (array)$replace[0]; | ||
} | ||
|
||
$reflectionObject = ObjectDefinition::getReflectionObject($object); | ||
|
||
if ($reflectionObject->isEnum() || ($reflectionObject->isInternal() && !$reflectionObject->isCloneable())) { | ||
return $object; | ||
} | ||
|
||
$newObject = self::cloneObject($reflectionObject, $object); | ||
|
||
foreach (ObjectDefinition::getProperties($reflectionObject) as $property) { | ||
if ($property->isReadOnly() && $property->isInitialized($newObject)) { | ||
continue; | ||
} | ||
|
||
$value = isset($replace[$property->name]) || array_key_exists($property->name, $replace) | ||
? self::copy($property->getValue($object), $replace[$property->name]) | ||
: self::copy($property->getValue($object)); | ||
|
||
$property->setValue($newObject, $value); | ||
} | ||
|
||
return $newObject; | ||
} | ||
|
||
private static function cloneObject(ReflectionObject $reflectionObject, object $object): object | ||
{ | ||
return match (true) { | ||
$reflectionObject->hasMethod('__clone') && $reflectionObject->isCloneable(), | ||
$reflectionObject->isInternal() && $reflectionObject->isCloneable() => clone $object, | ||
default => $reflectionObject->newInstanceWithoutConstructor(), | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Form\ChangeSet; | ||
|
||
use function is_array; | ||
|
||
final class ArrayTypeChangeSet implements ChangeSetTypeInterface | ||
{ | ||
/** | ||
* @param array<string|int, ChangeSetTypeInterface> $changeSet | ||
*/ | ||
public function __construct(private readonly array $changeSet) | ||
{ | ||
} | ||
|
||
public function isEmpty(): bool | ||
{ | ||
return empty($this->changeSet); | ||
} | ||
|
||
/** | ||
* @template T of mixed | ||
* | ||
* @param array<string|int, mixed>|T $entity | ||
* | ||
* @return array<string|int, mixed>|T | ||
*/ | ||
public function reflect(mixed $entity): mixed | ||
{ | ||
if (is_array($entity)) { | ||
foreach ($this->changeSet as $key => $item) { | ||
$entity[$key] = $item->reflect($entity[$key] ?? null); | ||
} | ||
} | ||
|
||
return $entity; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Form\ChangeSet; | ||
|
||
use Lexal\SteppedForm\ObjectDefinition; | ||
|
||
use function is_array; | ||
use function is_object; | ||
|
||
final class ChangeSet | ||
{ | ||
/** | ||
* @param object|array<string, mixed> $current | ||
* @param ($current is object ? object : array<string, mixed>) $previous | ||
*/ | ||
public static function compute(object|array $current, object|array $previous): ChangeSetTypeInterface | ||
{ | ||
return is_array($current) | ||
? self::computeArraysChangeSet($current, $previous)[1] | ||
: self::computeObjectsChangeSet($current, $previous)[1]; | ||
} | ||
|
||
/** | ||
* @template T of mixed | ||
* | ||
* @param T $current | ||
* @param T $previous | ||
* | ||
* @return array{0: bool, 1: ChangeSetTypeInterface} | ||
*/ | ||
private static function computeChangeSet(mixed $current, mixed $previous): array | ||
{ | ||
return match (true) { | ||
is_array($current) && is_array($previous) => self::computeArraysChangeSet($current, $previous), | ||
is_object($current) && is_object($previous) => self::computeObjectsChangeSet($current, $previous), | ||
default => [$current !== $previous, new SimpleTypeChangeSet($current)], | ||
}; | ||
} | ||
|
||
/** | ||
* @template TArray of array<string, mixed> | ||
* | ||
* @param TArray $current | ||
* @param TArray $previous | ||
* | ||
* @return array{0: bool, 1: ArrayTypeChangeSet, 2: array<string, ChangeSetTypeInterface>} | ||
*/ | ||
private static function computeArraysChangeSet(array $current, array $previous): array | ||
{ | ||
$changeSet = []; | ||
|
||
foreach ($current as $key => $value) { | ||
[$isChanged, $updates] = self::computeChangeSet($value, $previous[$key] ?? null); | ||
|
||
if ($isChanged) { | ||
$changeSet[$key] = $updates; | ||
} | ||
} | ||
|
||
return [!empty($changeSet), new ArrayTypeChangeSet($changeSet), $changeSet]; | ||
} | ||
|
||
/** | ||
* @param object $current | ||
* @param object $previous | ||
* | ||
* @return array{0: bool, 1: ObjectTypeChangeSet} | ||
*/ | ||
private static function computeObjectsChangeSet(object $current, object $previous): array | ||
{ | ||
if ($current::class !== $previous::class) { | ||
return [false, new ObjectTypeChangeSet([], $current)]; | ||
} | ||
|
||
[$isChanged, , $changeSet] = self::computeArraysChangeSet( | ||
self::objectToArray($current), | ||
self::objectToArray($previous), | ||
); | ||
|
||
return [$isChanged, new ObjectTypeChangeSet($changeSet, $current)]; | ||
} | ||
|
||
/** | ||
* @return array<string, mixed> | ||
*/ | ||
private static function objectToArray(object $object): array | ||
{ | ||
$data = []; | ||
|
||
foreach (ObjectDefinition::getPropertiesByObject($object) as $property) { | ||
if ($property->isInitialized($object)) { | ||
$data[$property->name] = $property->getValue($object); | ||
} | ||
} | ||
|
||
return $data; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Form\ChangeSet; | ||
|
||
interface ChangeSetTypeInterface | ||
{ | ||
/** | ||
* Checks if current change set is empty. | ||
*/ | ||
public function isEmpty(): bool; | ||
|
||
/** | ||
* Reflects current change set onto given entity. | ||
*/ | ||
public function reflect(mixed $entity): mixed; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Form\ChangeSet; | ||
|
||
use Lexal\SteppedForm\EntityCopy; | ||
use Lexal\SteppedForm\ObjectDefinition; | ||
use stdClass; | ||
|
||
use function is_object; | ||
|
||
final class ObjectTypeChangeSet implements ChangeSetTypeInterface | ||
{ | ||
/** | ||
* @param array<string, ChangeSetTypeInterface> $changeSet | ||
*/ | ||
public function __construct(private readonly array $changeSet, private readonly ?object $object) | ||
{ | ||
} | ||
|
||
public function isEmpty(): bool | ||
{ | ||
return empty($this->changeSet); | ||
} | ||
|
||
/** | ||
* @template T | ||
* | ||
* @param T $entity | ||
* | ||
* @return T | ||
*/ | ||
public function reflect(mixed $entity): mixed | ||
{ | ||
if (is_object($entity) && !empty($this->changeSet)) { | ||
$entity = $this->canBeReplaced($entity) | ||
? EntityCopy::copy($entity, $this->getPropertiesValues($entity)) | ||
: EntityCopy::copy($this->object); | ||
} | ||
|
||
return $entity; | ||
} | ||
|
||
/** | ||
* @return array<string, mixed> | ||
*/ | ||
private function getPropertiesValues(object $object): array | ||
{ | ||
$properties = []; | ||
|
||
foreach (ObjectDefinition::getPropertiesByObject($object) as $property) { | ||
if (isset($this->changeSet[$property->name])) { | ||
$properties[$property->name] = $this->changeSet[$property->name]->reflect($property->getValue($object)); | ||
} | ||
} | ||
|
||
return $properties; | ||
} | ||
|
||
private function canBeReplaced(object $object): bool | ||
{ | ||
if ($object instanceof stdClass) { | ||
return true; | ||
} | ||
|
||
$reflectionObject = ObjectDefinition::getReflectionObject($object); | ||
|
||
return !$reflectionObject->isInternal() && !$reflectionObject->isEnum(); | ||
Check warning on line 69 in src/Form/ChangeSet/ObjectTypeChangeSet.php
|
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Form\ChangeSet; | ||
|
||
use Lexal\SteppedForm\EntityCopy; | ||
|
||
final class SimpleTypeChangeSet implements ChangeSetTypeInterface | ||
{ | ||
public function __construct(private readonly mixed $changeSet) | ||
{ | ||
} | ||
|
||
public function isEmpty(): bool | ||
{ | ||
return false; | ||
} | ||
|
||
public function reflect(mixed $entity): mixed | ||
{ | ||
return EntityCopy::copy($this->changeSet); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,12 +5,15 @@ | |
namespace Lexal\SteppedForm\Form\Storage; | ||
|
||
use Lexal\SteppedForm\Exception\KeysNotFoundInStorageException; | ||
use Lexal\SteppedForm\Form\ChangeSet\ChangeSet; | ||
use Lexal\SteppedForm\Step\StepKey; | ||
|
||
use function array_keys; | ||
use function array_pop; | ||
use function array_search; | ||
use function array_slice; | ||
use function is_array; | ||
use function is_object; | ||
|
||
final class DataStorage implements DataStorageInterface | ||
{ | ||
|
@@ -45,6 +48,9 @@ | |
{ | ||
$data = $this->getData(); | ||
|
||
$this->checkAvailabilityToPut($key->value, $entity, $data); | ||
Check warning on line 51 in src/Form/Storage/DataStorage.php
|
||
|
||
$data = $this->reflect($key->value, $entity, $data); | ||
$data[$key->value] = $entity; | ||
|
||
$this->storage->put(self::STORAGE_KEY, $data); | ||
|
@@ -67,7 +73,7 @@ | |
*/ | ||
private function getData(): array | ||
{ | ||
return (array)$this->storage->get(self::STORAGE_KEY, []); | ||
Check warning on line 76 in src/Form/Storage/DataStorage.php
|
||
} | ||
|
||
/** | ||
|
@@ -78,6 +84,72 @@ | |
return array_keys($this->getData()); | ||
} | ||
|
||
/** | ||
* @param array<string, mixed> $data | ||
*/ | ||
private function checkAvailabilityToPut(string $key, mixed $entity, array $data): void | ||
{ | ||
$keys = $this->keys(); | ||
$index = $this->getIndex($key); | ||
|
||
if ($index === null || $index <= 0 || !isset($keys[$index - 1], $data[$keys[$index - 1]])) { | ||
Check warning on line 95 in src/Form/Storage/DataStorage.php
|
||
return; | ||
} | ||
|
||
$previous = $data[$keys[$index - 1]]; | ||
|
||
if ( | ||
(is_array($entity) && is_array($previous)) | ||
|| (is_object($entity) && is_object($previous) && $entity::class === $previous::class) | ||
) { | ||
return; | ||
} | ||
|
||
trigger_deprecation( | ||
'lexal/stepped-form', | ||
'3.1.0', | ||
'Entities should have the same type between steps. Only array or object allowed to use.', | ||
); | ||
} | ||
|
||
/** | ||
* @param array<string, mixed> $data | ||
* | ||
* @return array<string, mixed> | ||
*/ | ||
private function reflect(string $key, mixed $entity, array $data): array | ||
{ | ||
$index = $this->getIndex($key); | ||
|
||
if ($index === null && !isset($data[$key])) { | ||
return $data; | ||
} | ||
|
||
$changeSet = ChangeSet::compute($entity, $data[$key]); | ||
|
||
if ($changeSet->isEmpty()) { | ||
return $data; | ||
} | ||
|
||
foreach (array_slice($this->keys(), $index + 1) as $reflectKey) { | ||
if (isset($data[$reflectKey])) { | ||
$data[$reflectKey] = $changeSet->reflect($data[$reflectKey]); | ||
} | ||
} | ||
|
||
return $data; | ||
} | ||
|
||
private function getIndex(string $key): ?int | ||
{ | ||
$keys = $this->keys(); | ||
|
||
/** @var int|false $index */ | ||
$index = array_search($key, $keys, true); | ||
|
||
return $index === false ? null : $index; | ||
} | ||
|
||
private function forget(string ...$keys): void | ||
{ | ||
$data = $this->getData(); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm; | ||
|
||
use ReflectionObject; | ||
use ReflectionProperty; | ||
use stdClass; | ||
|
||
final class ObjectDefinition | ||
{ | ||
/** | ||
* @var array<class-string, ReflectionObject> | ||
*/ | ||
private static array $reflectionObjects = []; | ||
|
||
/** | ||
* @var array<class-string, array<string, ReflectionProperty>> | ||
*/ | ||
private static array $reflectionProperties = []; | ||
|
||
public static function getReflectionObject(object $object): ReflectionObject | ||
{ | ||
if ($object::class === stdClass::class) { | ||
return new ReflectionObject($object); | ||
} | ||
|
||
return self::$reflectionObjects[$object::class] ??= new ReflectionObject($object); | ||
} | ||
|
||
/** | ||
* @return array<string, ReflectionProperty> | ||
*/ | ||
public static function getPropertiesByObject(object $object): array | ||
{ | ||
return self::getProperties(self::getReflectionObject($object)); | ||
} | ||
|
||
/** | ||
* @return array<string, ReflectionProperty> | ||
*/ | ||
public static function getProperties(ReflectionObject $reflectedObject): array | ||
{ | ||
$className = $reflectedObject->name; | ||
|
||
if (isset(self::$reflectionProperties[$className])) { | ||
return self::$reflectionProperties[$className]; | ||
} | ||
|
||
$properties = []; | ||
|
||
do { | ||
foreach ($reflectedObject->getProperties() as $property) { | ||
if (!$property->isStatic()) { | ||
$properties[$property->name] = $property; | ||
} | ||
} | ||
} while ($reflectedObject = $reflectedObject->getParentClass()); | ||
|
||
return $className === stdClass::class ? $properties : self::$reflectionProperties[$className] = $properties; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests; | ||
|
||
final class CloneableEntity | ||
{ | ||
public function __construct(public readonly string $text, public string $name = 'name') | ||
{ | ||
} | ||
|
||
public function __clone(): void | ||
{ | ||
$this->name = 'name2'; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests; | ||
|
||
final class Entity | ||
{ | ||
public function __construct( | ||
public string $public, | ||
protected int $protected, | ||
private float $private, | ||
public readonly bool $publicReadonly, | ||
protected readonly string $protectedReadonly, | ||
private readonly string $privateReadonly, | ||
private readonly ?Entity $nested = null, | ||
) { | ||
} | ||
|
||
public function getPrivate(): float | ||
{ | ||
return $this->private; | ||
} | ||
|
||
public function getPrivateReadonly(): string | ||
{ | ||
return $this->privateReadonly; | ||
} | ||
|
||
public function getNested(): ?Entity | ||
{ | ||
return $this->nested; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,192 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests; | ||
|
||
use DateTime; | ||
use Exception; | ||
use Lexal\SteppedForm\EntityCopy; | ||
use PHPUnit\Framework\Attributes\DataProvider; | ||
use PHPUnit\Framework\TestCase; | ||
use stdClass; | ||
|
||
final class EntityCopyTest extends TestCase | ||
{ | ||
/** | ||
* @param array{0?: mixed} $replace | ||
*/ | ||
#[DataProvider('copyCommonDataProvider')] | ||
public function testCopyCommon(mixed $entity, array $replace, mixed $expected): void | ||
{ | ||
$copied = EntityCopy::copy($entity, ...$replace); | ||
|
||
self::assertEquals($expected, $copied); | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{0: mixed, 1: array{0?: mixed}, 2: mixed}> | ||
*/ | ||
public static function copyCommonDataProvider(): iterable | ||
{ | ||
yield 'copy array' => [ | ||
['id' => 5, 'name' => 'test', ['properties' => ['red', 'vehicle']]], | ||
[], | ||
['id' => 5, 'name' => 'test', ['properties' => ['red', 'vehicle']]], | ||
]; | ||
|
||
yield 'copy array with replace' => [ | ||
['id' => 5, 'name' => 'test', ['properties' => ['red', 'vehicle']]], | ||
[['name' => 'replaced', ['properties' => ['green']]]], | ||
['id' => 5, 'name' => 'replaced', ['properties' => ['green', 'vehicle']]], | ||
]; | ||
|
||
yield 'copy array when replace is not array' => [ | ||
['id' => 5, 'name' => 'test', ['properties' => ['red', 'vehicle']]], | ||
[5], | ||
['id' => 5, 'name' => 'test', ['properties' => ['red', 'vehicle']]], | ||
]; | ||
|
||
yield 'copy string' => ['string', [], 'string']; | ||
yield 'copy string with replace' => ['string', ['replaced'], 'replaced']; | ||
yield 'copy integer' => [5, [], 5]; | ||
yield 'copy integer with replace' => [5, [7], 7]; | ||
yield 'copy float' => [12.4, [], 12.4]; | ||
yield 'copy float with replace' => [12.4, [24.6], 24.6]; | ||
yield 'copy boolean' => [true, [], true]; | ||
yield 'copy boolean with replace' => [true, [false], false]; | ||
|
||
$entity = new Entity('string', 5, 14.8, true, 'readonly', 'private'); | ||
$std = new stdClass(); | ||
$std->name = 'name'; | ||
|
||
$entity2 = new Entity('string', 16, 14.8, true, 'readonly', 'private'); | ||
$std2 = new stdClass(); | ||
$std2->name = 'replaced'; | ||
|
||
yield 'copy mixed types' => [ | ||
['id' => 5, 'entity' => $entity, 'std' => $std, 'enum' => EntityEnum::Active], | ||
[['entity' => ['protected' => 16], 'std' => ['name' => 'replaced'], 'enum' => EntityEnum::Canceled]], | ||
['id' => 5, 'entity' => $entity2, 'std' => $std2, 'enum' => EntityEnum::Active], | ||
]; | ||
} | ||
|
||
/** | ||
* @param array{0?: array<string, mixed>} $replace | ||
*/ | ||
#[DataProvider('copyObjectsDataProvider')] | ||
public function testCopyObjects(mixed $entity, array $replace, mixed $expected): void | ||
{ | ||
$copied = EntityCopy::copy($entity, ...$replace); | ||
|
||
self::assertEquals($expected, $copied); | ||
self::assertNotSame($entity, $copied); | ||
|
||
if ($entity instanceof Entity) { | ||
self::assertNotSame($entity->getNested(), $copied->getNested()); | ||
} | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{0: object, 1: array{0?: int|array<string, mixed>}, 2: object}> | ||
*/ | ||
public static function copyObjectsDataProvider(): iterable | ||
{ | ||
$nested = new Entity('string2', 42, 26.7, false, 'readonly2', 'private2'); | ||
$entity = new Entity('string', 5, 14.8, true, 'readonly', 'private', $nested); | ||
|
||
yield 'copy object' => [$entity, [], $entity]; | ||
|
||
$nested2 = new Entity('replaced4', 68, 127.45, true, 'replaced5', 'replaced6'); | ||
$entity2 = new Entity('replaced', 8, 17.98, false, 'replaced2', 'replaced3', $nested2); | ||
|
||
yield 'copy object with replace' => [ | ||
$entity, | ||
[ | ||
[ | ||
'public' => 'replaced', | ||
'protected' => 8, | ||
'private' => 17.98, | ||
'publicReadonly' => false, | ||
'protectedReadonly' => 'replaced2', | ||
'privateReadonly' => 'replaced3', | ||
'nested' => [ | ||
'public' => 'replaced4', | ||
'protected' => 68, | ||
'private' => 127.45, | ||
'publicReadonly' => true, | ||
'protectedReadonly' => 'replaced5', | ||
'privateReadonly' => 'replaced6', | ||
], | ||
], | ||
], | ||
$entity2, | ||
]; | ||
|
||
$entity2 = new Entity('string', 5, 64.8, true, 'readonly', 'replaced3'); | ||
|
||
yield 'copy object with replace some properties' => [ | ||
$entity, | ||
[ | ||
[ | ||
'private' => 64.8, | ||
'privateReadonly' => 'replaced3', | ||
'nested' => null, | ||
], | ||
], | ||
$entity2, | ||
]; | ||
|
||
$entity = new stdClass(); | ||
$entity->name = 'test'; | ||
|
||
yield 'copy stdClass without replace' => [$entity, [], $entity]; | ||
|
||
$entity2 = new stdClass(); | ||
$entity2->name = 'replaced'; | ||
|
||
yield 'copy stdClass with replace' => [$entity, [['name' => 'replaced']], $entity2]; | ||
|
||
yield 'copy stdClass when replace is not array' => [$entity, [5], $entity]; | ||
|
||
yield 'copy internal objects' => [ | ||
new DateTime('2024-04-05'), | ||
[['timezone' => 'Pacific/Nauru']], | ||
new DateTime('2024-04-05'), | ||
]; | ||
|
||
yield 'copy cloneable object' => [ | ||
new CloneableEntity('text'), | ||
[['text' => 'replace', 'name' => 'rename']], | ||
new CloneableEntity('text', 'rename'), | ||
]; | ||
} | ||
|
||
/** | ||
* @param array{0?: array<string, mixed>} $replace | ||
*/ | ||
#[DataProvider('copyUncloneableDataProvider')] | ||
public function testCopyUncloneable(object $object, array $replace, object $expected): void | ||
{ | ||
$copied = EntityCopy::copy($object, ...$replace); | ||
|
||
self::assertSame($expected, $copied); | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{0: object, 1: array{0?: array<string, mixed>}, 2: object}> | ||
*/ | ||
public static function copyUncloneableDataProvider(): iterable | ||
{ | ||
yield 'copy enum without replace' => [EntityEnum::Active, [], EntityEnum::Active]; | ||
yield 'copy enum with replace' => [ | ||
EntityEnum::Active, | ||
[['name' => 'Canceled', 'value' => 'canceled']], | ||
EntityEnum::Active, | ||
]; | ||
|
||
$object = new Exception('test'); | ||
|
||
yield 'copy uncloneable object' => [$object, [['message' => 'rename']], $object]; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
<?php | ||
|
||
namespace Lexal\SteppedForm\Tests; | ||
|
||
enum EntityEnum: string | ||
{ | ||
case Active = 'active'; | ||
case Canceled = 'canceled'; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests\Form\ChangeSet; | ||
|
||
use Lexal\SteppedForm\Form\ChangeSet\ArrayTypeChangeSet; | ||
use Lexal\SteppedForm\Form\ChangeSet\ChangeSetTypeInterface; | ||
use Lexal\SteppedForm\Form\ChangeSet\SimpleTypeChangeSet; | ||
use PHPUnit\Framework\Attributes\DataProvider; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
final class ArrayTypeChangeSetTest extends TestCase | ||
{ | ||
/** | ||
* @param array<string, ChangeSetTypeInterface> $changeSet | ||
*/ | ||
#[DataProvider('isEmptyDataProvider')] | ||
public function testIsEmpty(array $changeSet, bool $expected): void | ||
{ | ||
$changeSet = new ArrayTypeChangeSet($changeSet); | ||
|
||
self::assertEquals($expected, $changeSet->isEmpty()); | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{0: array<string, ChangeSetTypeInterface>, 1: bool}> | ||
*/ | ||
public static function isEmptyDataProvider(): iterable | ||
{ | ||
yield 'is empty' => [[], true]; | ||
yield 'is not empty' => [['name' => new SimpleTypeChangeSet('rename')], false]; | ||
} | ||
|
||
/** | ||
* @param array<string, mixed> $reflectOn | ||
* @param array<string, mixed> $expected | ||
*/ | ||
#[DataProvider('reflectOnArrayDataProvider')] | ||
public function testReflectOnArray(array $reflectOn, array $expected): void | ||
{ | ||
$changeSet = new ArrayTypeChangeSet([ | ||
'name' => new SimpleTypeChangeSet('name'), | ||
'new' => new SimpleTypeChangeSet(24), | ||
]); | ||
|
||
self::assertEquals($expected, $changeSet->reflect($reflectOn)); | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{0: array<string, mixed>, 1: array<string, mixed>}> | ||
*/ | ||
public static function reflectOnArrayDataProvider(): iterable | ||
{ | ||
yield 'reflect on array' => [ | ||
['name' => 'before', 'color' => 'red'], | ||
['name' => 'name', 'new' => 24, 'color' => 'red'], | ||
]; | ||
|
||
yield 'reflect on empty array' => [[], ['name' => 'name', 'new' => 24]]; | ||
} | ||
|
||
public function testReflectIsNotArray(): void | ||
{ | ||
$changeSet = new ArrayTypeChangeSet(['name' => new SimpleTypeChangeSet('name')]); | ||
|
||
self::assertEquals(18, $changeSet->reflect(18)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests\Form\ChangeSet; | ||
|
||
use Lexal\SteppedForm\Form\ChangeSet\ArrayTypeChangeSet; | ||
use Lexal\SteppedForm\Form\ChangeSet\ChangeSet; | ||
use Lexal\SteppedForm\Form\ChangeSet\ChangeSetTypeInterface; | ||
use Lexal\SteppedForm\Form\ChangeSet\ObjectTypeChangeSet; | ||
use Lexal\SteppedForm\Form\ChangeSet\SimpleTypeChangeSet; | ||
use Lexal\SteppedForm\Tests\SimpleEntity; | ||
use PHPUnit\Framework\Attributes\DataProvider; | ||
use PHPUnit\Framework\TestCase; | ||
use stdClass; | ||
|
||
final class ChangeSetTest extends TestCase | ||
{ | ||
/** | ||
* @param object|array<string, mixed> $current | ||
* @param object|array<string, mixed> $previous | ||
*/ | ||
#[DataProvider('computeChangeSetDataProvider')] | ||
public function testComputeChangeSet( | ||
object|array $current, | ||
object|array $previous, | ||
ChangeSetTypeInterface $expected, | ||
): void { | ||
$changeSet = ChangeSet::compute($current, $previous); | ||
|
||
self::assertEquals($expected, $changeSet); | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{ | ||
* 0: object|array<string, mixed>, | ||
* 1: object|array<string, mixed>, | ||
* 2: ChangeSetTypeInterface, | ||
* }> | ||
*/ | ||
public static function computeChangeSetDataProvider(): iterable | ||
{ | ||
$current = [ | ||
'id' => 5, | ||
'name' => 'test', | ||
'properties' => [ | ||
['color' => 'red'], | ||
['brand' => 'brady'], | ||
], | ||
'price' => [ | ||
'original' => 126, | ||
'discount' => 14, | ||
], | ||
'rating' => 4.7, | ||
'object' => self::createObject(['name' => 'test', 'nested' => self::createObject(['name' => 'nested'])]), | ||
]; | ||
|
||
yield 'compute for two arrays without changes' => [$current, $current, new ArrayTypeChangeSet([])]; | ||
|
||
$current = [ | ||
'id' => 5, | ||
'name' => 'rename', | ||
'properties' => [ | ||
['color' => 'red', 'condition' => 'new'], | ||
['brand' => 'brady'], | ||
['type' => 'vehicle'], | ||
], | ||
'price' => [ | ||
'original' => 126, | ||
'discount' => 18, | ||
], | ||
'rating' => 4.69, | ||
'created_at' => '2024-04-06', | ||
'object' => self::createObject([ | ||
'name' => 'test', | ||
'nested' => self::createObject(['name' => 'nested', 'color' => 'red']), | ||
]), | ||
'new_object' => self::createObject(['name' => 'test']), | ||
'changed_type' => 'string', | ||
]; | ||
|
||
$previous = [ | ||
'id' => 5, | ||
'name' => 'test', | ||
'properties' => [ | ||
['color' => 'red'], | ||
['brand' => 'brady'], | ||
], | ||
'price' => [ | ||
'original' => 126, | ||
'discount' => 14, | ||
], | ||
'rating' => 4.7, | ||
'object' => self::createObject(['name' => 'rename', 'nested' => self::createObject(['name' => 'nested'])]), | ||
'changed_type' => true, | ||
]; | ||
|
||
yield 'compute for two arrays with changes' => [ | ||
$current, | ||
$previous, | ||
new ArrayTypeChangeSet([ | ||
'name' => new SimpleTypeChangeSet('rename'), | ||
'properties' => new ArrayTypeChangeSet([ | ||
0 => new ArrayTypeChangeSet(['condition' => new SimpleTypeChangeSet('new')]), | ||
2 => new SimpleTypeChangeSet(['type' => 'vehicle']), | ||
]), | ||
'price' => new ArrayTypeChangeSet(['discount' => new SimpleTypeChangeSet(18)]), | ||
'rating' => new SimpleTypeChangeSet(4.69), | ||
'created_at' => new SimpleTypeChangeSet('2024-04-06'), | ||
'object' => new ObjectTypeChangeSet( | ||
[ | ||
'name' => new SimpleTypeChangeSet('test'), | ||
'nested' => new ObjectTypeChangeSet( | ||
['color' => new SimpleTypeChangeSet('red')], | ||
self::createObject(['name' => 'nested', 'color' => 'red']), | ||
), | ||
], | ||
self::createObject([ | ||
'name' => 'test', | ||
'nested' => self::createObject(['name' => 'nested', 'color' => 'red']), | ||
]), | ||
), | ||
'new_object' => new SimpleTypeChangeSet(self::createObject(['name' => 'test'])), | ||
'changed_type' => new SimpleTypeChangeSet('string'), | ||
]), | ||
]; | ||
|
||
$current = self::createObject([ | ||
'id' => 5, | ||
'name' => 'test', | ||
'properties' => [ | ||
self::createObject(['name' => 'color', 'value' => 'red']), | ||
self::createObject(['name' => 'brand', 'value' => 'brady']), | ||
], | ||
'price' => self::createObject(['original' => 126, 'discount' => 14]), | ||
'rating' => 4.7, | ||
]); | ||
|
||
yield 'compute for two objects without changes' => [$current, $current, new ObjectTypeChangeSet([], $current)]; | ||
|
||
$current = self::createObject([ | ||
'id' => 5, | ||
'name' => 'rename', | ||
'properties' => [ | ||
self::createObject(['name' => 'type', 'value' => 'vehicle']), | ||
self::createObject(['name' => 'brand', 'value' => 'brady']), | ||
self::createObject(['name' => 'color', 'value' => 'red']), | ||
], | ||
'price' => self::createObject(['original' => 126, 'discount' => 18]), | ||
'rating' => 4.69, | ||
'created_at' => '2024-04-06', | ||
'change_type' => true, | ||
]); | ||
|
||
$previous = self::createObject([ | ||
'id' => 5, | ||
'name' => 'test', | ||
'properties' => [ | ||
self::createObject(['name' => 'color', 'value' => 'red']), | ||
self::createObject(['name' => 'brand', 'value' => 'brady']), | ||
], | ||
'price' => self::createObject(['original' => 126, 'discount' => 14]), | ||
'rating' => 4.7, | ||
'change_type' => 'string', | ||
]); | ||
|
||
yield 'compute for two objects with changes' => [ | ||
$current, | ||
$previous, | ||
new ObjectTypeChangeSet( | ||
[ | ||
'name' => new SimpleTypeChangeSet('rename'), | ||
'properties' => new ArrayTypeChangeSet([ | ||
0 => new ObjectTypeChangeSet( | ||
[ | ||
'name' => new SimpleTypeChangeSet('type'), | ||
'value' => new SimpleTypeChangeSet('vehicle'), | ||
], | ||
self::createObject(['name' => 'type', 'value' => 'vehicle']), | ||
), | ||
2 => new SimpleTypeChangeSet(self::createObject(['name' => 'color', 'value' => 'red'])), | ||
]), | ||
'price' => new ObjectTypeChangeSet( | ||
['discount' => new SimpleTypeChangeSet(18)], | ||
self::createObject(['original' => 126, 'discount' => 18]), | ||
), | ||
'rating' => new SimpleTypeChangeSet(4.69), | ||
'created_at' => new SimpleTypeChangeSet('2024-04-06'), | ||
'change_type' => new SimpleTypeChangeSet(true), | ||
], | ||
$current, | ||
), | ||
]; | ||
|
||
$current = ['object' => self::createObject(['name' => 'rename'])]; | ||
$previous = ['object' => new SimpleEntity()]; | ||
|
||
yield 'compute for two different objects' => [$current, $previous, new ArrayTypeChangeSet([])]; | ||
} | ||
|
||
/** | ||
* @param array<string, mixed> $properties | ||
*/ | ||
private static function createObject(array $properties): object | ||
{ | ||
$object = new stdClass(); | ||
|
||
foreach ($properties as $name => $value) { | ||
$object->{$name} = $value; | ||
} | ||
|
||
return $object; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests\Form\ChangeSet; | ||
|
||
use DateTime; | ||
use Lexal\SteppedForm\Form\ChangeSet\ChangeSetTypeInterface; | ||
use Lexal\SteppedForm\Form\ChangeSet\ObjectTypeChangeSet; | ||
use Lexal\SteppedForm\Form\ChangeSet\SimpleTypeChangeSet; | ||
use Lexal\SteppedForm\Tests\EntityEnum; | ||
use PHPUnit\Framework\Attributes\DataProvider; | ||
use PHPUnit\Framework\TestCase; | ||
use stdClass; | ||
|
||
final class ObjectTypeChangeSetTest extends TestCase | ||
{ | ||
/** | ||
* @param array<string, ChangeSetTypeInterface> $changeSet | ||
*/ | ||
#[DataProvider('isEmptyDataProvider')] | ||
public function testIsEmpty(array $changeSet, bool $expected): void | ||
{ | ||
$changeSet = new ObjectTypeChangeSet($changeSet, new stdClass()); | ||
|
||
self::assertEquals($expected, $changeSet->isEmpty()); | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{0: array<string, ChangeSetTypeInterface>, 1: bool}> | ||
*/ | ||
public static function isEmptyDataProvider(): iterable | ||
{ | ||
yield 'is empty' => [[], true]; | ||
yield 'is not empty' => [['name' => new SimpleTypeChangeSet('rename')], false]; | ||
} | ||
|
||
/** | ||
* @param array<string, ChangeSetTypeInterface> $changeSet | ||
*/ | ||
#[DataProvider('reflectDataProvider')] | ||
public function testReflect(mixed $reflectOn, array $changeSet, ?object $object, mixed $expected): void | ||
{ | ||
$changeSet = new ObjectTypeChangeSet($changeSet, $object); | ||
|
||
$reflected = $changeSet->reflect($reflectOn); | ||
|
||
self::assertEquals($expected, $reflected); | ||
self::assertNotSame($object, $reflected); | ||
self::assertNotSame($reflectOn, $reflected); | ||
} | ||
|
||
/** | ||
* @return iterable<string, array{0: object, 1: array<string, ChangeSetTypeInterface>, 2: object, 3: object}> | ||
*/ | ||
public static function reflectDataProvider(): iterable | ||
{ | ||
$reflectOn = new stdClass(); | ||
$reflectOn->name = 'name'; | ||
$reflectOn->type = 'type'; | ||
$reflectOn->color = 'red'; | ||
|
||
$object = new stdClass(); | ||
$object->name = 'replaced'; | ||
$object->type = 'rename'; | ||
$object->color = 'blue'; | ||
|
||
$expected = new stdClass(); | ||
$expected->name = 'replaced'; | ||
$expected->type = 'rename'; | ||
$expected->color = 'red'; | ||
|
||
yield 'reflect on object with change set' => [ | ||
$reflectOn, | ||
['name' => new SimpleTypeChangeSet('replaced'), 'type' => new SimpleTypeChangeSet('rename')], | ||
$object, | ||
$expected, | ||
]; | ||
|
||
$date = new DateTime('2024-04-05'); | ||
$expected = new DateTime('2024-04-06'); | ||
|
||
yield 'reflect on internal class' => [ | ||
$date, | ||
['timezone' => new SimpleTypeChangeSet('Europe/Tallinn')], | ||
$expected, | ||
$expected, | ||
]; | ||
} | ||
|
||
public function testReflectOnEnum(): void | ||
{ | ||
$changeSet = new ObjectTypeChangeSet( | ||
[ | ||
'name' => new SimpleTypeChangeSet('Canceled'), | ||
'value' => new SimpleTypeChangeSet('Canceled'), | ||
], | ||
EntityEnum::Canceled, | ||
); | ||
|
||
$reflected = $changeSet->reflect(EntityEnum::Active); | ||
|
||
self::assertEquals(EntityEnum::Canceled, $reflected); | ||
} | ||
|
||
public function testReflectOnNotAndObject(): void | ||
{ | ||
$object = new stdClass(); | ||
$object->name = 'name'; | ||
|
||
$changeSet = new ObjectTypeChangeSet(['name' => new SimpleTypeChangeSet('replaced')], $object); | ||
|
||
self::assertSame(18, $changeSet->reflect(18)); | ||
} | ||
|
||
public function testReflectOnObjectWithoutChangeSet(): void | ||
{ | ||
$object = new stdClass(); | ||
$object->name = 'name'; | ||
|
||
$changeSet = new ObjectTypeChangeSet([], $object); | ||
|
||
self::assertSame($object, $changeSet->reflect($object)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests\Form\ChangeSet; | ||
|
||
use Lexal\SteppedForm\Form\ChangeSet\SimpleTypeChangeSet; | ||
use PHPUnit\Framework\TestCase; | ||
use stdClass; | ||
|
||
final class SimpleTypeChangeSetTest extends TestCase | ||
{ | ||
public function testIsEmpty(): void | ||
{ | ||
$changeSet = new SimpleTypeChangeSet(15); | ||
|
||
self::assertFalse($changeSet->isEmpty()); | ||
} | ||
|
||
public function testReflectOnScalar(): void | ||
{ | ||
$changeSet = new SimpleTypeChangeSet(15); | ||
|
||
self::assertEquals(15, $changeSet->reflect(18)); | ||
} | ||
|
||
public function testReflectOnObject(): void | ||
{ | ||
$object = new stdClass(); | ||
$object->namge = 'name'; | ||
|
||
$changeSet = new SimpleTypeChangeSet($object); | ||
|
||
$reflected = $changeSet->reflect(18); | ||
|
||
self::assertEquals($object, $reflected); | ||
self::assertNotSame($object, $reflected); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests; | ||
|
||
use Lexal\SteppedForm\ObjectDefinition; | ||
use PHPUnit\Framework\TestCase; | ||
use stdClass; | ||
|
||
use function array_keys; | ||
|
||
final class ObjectDefinitionTest extends TestCase | ||
{ | ||
public function testGetReflectionForStdObject(): void | ||
{ | ||
$object = $this->createStdObject(); | ||
$reflectionObject = ObjectDefinition::getReflectionObject($object); | ||
|
||
self::assertNotSame($reflectionObject, ObjectDefinition::getReflectionObject($object)); | ||
} | ||
|
||
public function testGetReflectionForCustomObject(): void | ||
{ | ||
$object = new SimpleEntity(); | ||
$reflectionObject = ObjectDefinition::getReflectionObject($object); | ||
|
||
self::assertSame($reflectionObject, ObjectDefinition::getReflectionObject($object)); | ||
} | ||
|
||
public function testGetPropertiesOfStdObject(): void | ||
{ | ||
$object = $this->createStdObject(); | ||
$properties = ObjectDefinition::getPropertiesByObject($object); | ||
|
||
self::assertEquals(['name'], array_keys($properties)); | ||
|
||
$object->color = 'red'; | ||
$properties = ObjectDefinition::getPropertiesByObject($object); | ||
|
||
self::assertEquals(['name', 'color'], array_keys($properties)); | ||
} | ||
|
||
public function testGetPropertiesOfCustomObject(): void | ||
{ | ||
$object = new SimpleEntity(); | ||
$properties = ObjectDefinition::getPropertiesByObject($object); | ||
|
||
self::assertEquals(['name', 'price'], array_keys($properties)); | ||
} | ||
|
||
private function createStdObject(): stdClass | ||
{ | ||
$object = new stdClass(); | ||
|
||
$object->name = 'name'; | ||
|
||
return $object; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests; | ||
|
||
final class SimpleEntity extends SimpleParent | ||
{ | ||
public static string $color = 'red'; | ||
|
||
public string $name = 'name'; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Lexal\SteppedForm\Tests; | ||
|
||
class SimpleParent | ||
{ | ||
public static string $text = ''; | ||
private int $price = 0; // @phpstan-ignore-line | ||
} |