From fc58f17153c17302fa7714ba70b3b6ec471431f9 Mon Sep 17 00:00:00 2001 From: Composite PHP Date: Sat, 25 Nov 2023 13:37:11 +0000 Subject: [PATCH] Add Collection column type --- composer.json | 3 +- src/ColumnBuilder.php | 137 ++++++++------ src/Columns/AbstractColumn.php | 1 + src/Columns/CollectionColumn.php | 56 ++++++ src/Columns/EntityColumn.php | 3 + src/Columns/EntityListColumn.php | 36 +--- src/Schema.php | 17 +- tests/Columns/CollectionColumnTest.php | 226 +++++++++++++++++++++++ tests/Columns/EntityListColumnTest.php | 16 +- tests/TestStand/TestEntityCollection.php | 11 ++ tests/TestStand/TestStringCollection.php | 11 ++ 11 files changed, 409 insertions(+), 108 deletions(-) create mode 100644 src/Columns/CollectionColumn.php create mode 100644 tests/Columns/CollectionColumnTest.php create mode 100644 tests/TestStand/TestEntityCollection.php create mode 100644 tests/TestStand/TestStringCollection.php diff --git a/composer.json b/composer.json index 970c279..b856e39 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "require-dev": { "phpunit/phpunit": "^10.1", "phpstan/phpstan": "^1.9.2", - "phpunit/php-code-coverage": "^10.1" + "phpunit/php-code-coverage": "^10.1", + "doctrine/collections": "^2.1" }, "autoload": { "psr-4": { diff --git a/src/ColumnBuilder.php b/src/ColumnBuilder.php index aa2e6f8..3bede87 100644 --- a/src/ColumnBuilder.php +++ b/src/ColumnBuilder.php @@ -2,7 +2,6 @@ namespace Composite\Entity; -use Composite\Entity\Columns; use Composite\Entity\Exceptions\EntityException; use Ramsey\Uuid\UuidInterface; @@ -40,10 +39,6 @@ public static function fromReflection(\ReflectionClass $reflectionClass): array if ($property->isStatic() || $property->isPrivate()) { continue; } - $type = $property->getType(); - if (!$type instanceof \ReflectionNamedType) { - throw new EntityException("Property `{$property->name}` must have named type"); - } /** @var array $propertyAttributes */ $propertyAttributes = []; foreach ($property->getAttributes() as $attribute) { @@ -53,6 +48,10 @@ public static function fromReflection(\ReflectionClass $reflectionClass): array } $propertyAttributes[$attributeInstance::class] = $attributeInstance; } + $type = $property->getType(); + if (!$type instanceof \ReflectionNamedType) { + throw new EntityException("Property `{$property->name}` must have named type"); + } if (array_key_exists($property->name, $constructorDefaultValues)) { $hasDefaultValue = true; @@ -64,65 +63,87 @@ public static function fromReflection(\ReflectionClass $reflectionClass): array $hasDefaultValue = false; $defaultValue = null; } + [ + 'columnClass' => $columnClass, + 'type' => $typeName, + 'subType' => $subType, + ] = self::getPropertyConfig($type->getName(), $propertyAttributes); - if (isset($propertyAttributes[Attributes\ListOf::class])) { - if ($type->getName() !== 'array') { - throw new EntityException("Property `{$property->name}` has ListOf attribute and must have array type."); - } - /** @var Attributes\ListOf $listOfAttribute */ - $listOfAttribute = $propertyAttributes[Attributes\ListOf::class]; - $typeName = $listOfAttribute->class; + $result[$property->getName()] = new $columnClass( + name: $property->getName(), + type: $typeName, + subType: $subType, + attributes: $propertyAttributes, + hasDefaultValue: $hasDefaultValue, + defaultValue: $defaultValue, + isNullable: $type->allowsNull(), + isReadOnly: $property->isReadOnly(), + isConstructorPromoted: !empty($constructorColumns[$property->getName()]), + ); + } + return $result; + } - $result[$property->getName()] = new Columns\EntityListColumn( - name: $property->getName(), - type: $typeName, - keyColumn: $listOfAttribute->keyColumn, - attributes: $propertyAttributes, - hasDefaultValue: $hasDefaultValue, - defaultValue: $defaultValue, - isNullable: $type->allowsNull(), - isReadOnly: $property->isReadOnly(), - isConstructorPromoted: !empty($constructorColumns[$property->getName()]), - ); - } else { - $typeName = $type->getName(); - $columnClass = self::PRIMITIVE_COLUMN_MAP[$typeName] ?? null; + /** + * @param array $propertyAttributes + * @return array{'columnClass': class-string, 'type': string, 'subType': string} + */ + private static function getPropertyConfig(string $propertyTypeName, array $propertyAttributes = []): array + { + $columnClass = self::PRIMITIVE_COLUMN_MAP[$propertyTypeName] ?? null; + $type = $propertyTypeName; + $subType = null; - if (!$columnClass && class_exists($typeName)) { - if (is_subclass_of($typeName, AbstractEntity::class)) { - $columnClass = Columns\EntityColumn::class; - } elseif (is_subclass_of($typeName, \BackedEnum::class)) { - $reflectionEnum = new \ReflectionEnum($typeName); - /** @var \ReflectionNamedType $backingType */ - $backingType = $reflectionEnum->getBackingType(); - if ($backingType->getName() === 'int') { - $columnClass = Columns\BackedIntEnumColumn::class; - } else { - $columnClass = Columns\BackedStringEnumColumn::class; - } - } elseif (is_subclass_of($typeName, \UnitEnum::class)) { - $columnClass = Columns\UnitEnumColumn::class; - } else { - if (in_array(CastableInterface::class, class_implements($typeName) ?: [])) { - $columnClass = Columns\CastableColumn::class; - } - } + if ($columnClass === Columns\ArrayColumn::class && isset($propertyAttributes[Attributes\ListOf::class])) { + $columnClass = Columns\EntityListColumn::class; + /** @var Attributes\ListOf $listOfAttribute */ + $listOfAttribute = $propertyAttributes[Attributes\ListOf::class]; + $type = $listOfAttribute->class; + $subType = $listOfAttribute->keyColumn; + } elseif (!$columnClass && class_exists($propertyTypeName)) { + if (is_subclass_of($propertyTypeName, AbstractEntity::class)) { + $columnClass = Columns\EntityColumn::class; + } elseif (is_subclass_of($propertyTypeName, \BackedEnum::class)) { + $reflectionEnum = new \ReflectionEnum($propertyTypeName); + /** @var \ReflectionNamedType $backingType */ + $backingType = $reflectionEnum->getBackingType(); + if ($backingType->getName() === 'int') { + $columnClass = Columns\BackedIntEnumColumn::class; + } else { + $columnClass = Columns\BackedStringEnumColumn::class; } - if (!$columnClass) { - throw new EntityException("Type `{$property->getType()}` is not supported"); + } elseif (is_subclass_of($propertyTypeName, \UnitEnum::class)) { + $columnClass = Columns\UnitEnumColumn::class; + } else { + $classInterfaces = array_fill_keys(class_implements($propertyTypeName), true); + if (!empty($classInterfaces[CastableInterface::class])) { + $columnClass = Columns\CastableColumn::class; + } elseif (!empty($classInterfaces[\ArrayAccess::class]) + && (!empty($classInterfaces[\Iterator::class]) || !empty($classInterfaces[\IteratorAggregate::class]))) { + $columnClass = Columns\CollectionColumn::class; + $reflectionMethod = new \ReflectionMethod($propertyTypeName, 'offsetGet'); + $returnType = $reflectionMethod->getReturnType(); + [ + 'columnClass' => $collectionItemClass, + 'type' => $collectionItemTypeName, + ] = self::getPropertyConfig($returnType->getName()); + $subType = new $collectionItemClass( + name: $propertyTypeName, + type: $collectionItemTypeName, + subType: null, + attributes: [], + hasDefaultValue: false, + defaultValue: null, + isNullable: true, + isReadOnly: false, + isConstructorPromoted: false, + ); } - $result[$property->getName()] = new $columnClass( - name: $property->getName(), - type: $typeName, - attributes: $propertyAttributes, - hasDefaultValue: $hasDefaultValue, - defaultValue: $defaultValue, - isNullable: $type->allowsNull(), - isReadOnly: $property->isReadOnly(), - isConstructorPromoted: !empty($constructorColumns[$property->getName()]), - ); } } - return $result; + if (!$columnClass) { + throw new EntityException("Type `{$propertyTypeName}` is not supported"); + } + return ['columnClass' => $columnClass, 'type' => $type, 'subType' => $subType]; } } \ No newline at end of file diff --git a/src/Columns/AbstractColumn.php b/src/Columns/AbstractColumn.php index 06b1df1..4ca77e3 100644 --- a/src/Columns/AbstractColumn.php +++ b/src/Columns/AbstractColumn.php @@ -14,6 +14,7 @@ abstract class AbstractColumn public function __construct( public readonly string $name, public readonly string $type, + public readonly string|AbstractColumn|null $subType, public readonly array $attributes, public readonly bool $hasDefaultValue, public readonly mixed $defaultValue, diff --git a/src/Columns/CollectionColumn.php b/src/Columns/CollectionColumn.php new file mode 100644 index 0000000..79a7495 --- /dev/null +++ b/src/Columns/CollectionColumn.php @@ -0,0 +1,56 @@ +getMessage(), $e); + } + } elseif (!is_array($dbValue)) { + throw new EntityException("Cannot to cast value for column {$this->name}, it must be string or array."); + } + /** @var \ArrayAccess $collection */ + $collection = new $this->type; + foreach ($dbValue as $data) { + if ($data === null) { + continue; + } + $item = $this->subType->cast($data); + $collection[] = $item; + } + return $collection; + } + + /** + * @param \Iterator|\IteratorAggregate $entityValue + * @throws EntityException + */ + public function uncast(mixed $entityValue): string + { + $list = []; + foreach ($entityValue as $item) { + if ($item instanceof AbstractEntity) { + $list[] = $item->toArray(); + } else { + $list[] = $this->subType->uncast($item); + } + } + try { + return \json_encode($list, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new EntityException($e->getMessage(), $e); + } + } +} \ No newline at end of file diff --git a/src/Columns/EntityColumn.php b/src/Columns/EntityColumn.php index 290fc23..50a374d 100644 --- a/src/Columns/EntityColumn.php +++ b/src/Columns/EntityColumn.php @@ -17,6 +17,9 @@ public function cast(mixed $dbValue): AbstractEntity if ($dbValue instanceof $className) { return $dbValue; } + if (is_array($dbValue)) { + return $className::fromArray($dbValue); + } try { $data = (array)\json_decode(strval($dbValue), true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { diff --git a/src/Columns/EntityListColumn.php b/src/Columns/EntityListColumn.php index 97c3a98..258600f 100644 --- a/src/Columns/EntityListColumn.php +++ b/src/Columns/EntityListColumn.php @@ -7,34 +7,6 @@ class EntityListColumn extends AbstractColumn { - private readonly ?string $keyColumn; - /** - * @param array $attributes - */ - public function __construct( - string $name, - string $type, - ?string $keyColumn, - array $attributes, - bool $hasDefaultValue, - mixed $defaultValue, - bool $isNullable, - bool $isReadOnly, - bool $isConstructorPromoted, - ) { - $this->keyColumn = $keyColumn; - parent::__construct( - name: $name, - type: $type, - attributes: $attributes, - hasDefaultValue: $hasDefaultValue, - defaultValue: $defaultValue, - isNullable: $isNullable, - isReadOnly: $isReadOnly, - isConstructorPromoted: $isConstructorPromoted, - ); - } - /** * @return array * @throws EntityException @@ -55,8 +27,8 @@ public function cast(mixed $dbValue): array if (!$entity = $this->getEntity($data)) { continue; } - if ($this->keyColumn && isset($entity->{$this->keyColumn})) { - $result[$entity->{$this->keyColumn}] = $entity; + if ($this->subType && isset($entity->{$this->subType})) { + $result[$entity->{$this->subType}] = $entity; } else { $result[] = $entity; } @@ -74,8 +46,8 @@ public function uncast(mixed $entityValue): string foreach ($entityValue as $item) { if ($item instanceof $this->type) { $data = $item->toArray(); - if ($this->keyColumn && isset($data[$this->keyColumn])) { - $list[$data[$this->keyColumn]] = $data; + if ($this->subType && isset($data[$this->subType])) { + $list[$data[$this->subType]] = $data; } else { $list[] = $data; } diff --git a/src/Schema.php b/src/Schema.php index 112ef2b..8877f4a 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -7,6 +7,8 @@ class Schema { + /** @var class-string $class */ + public readonly string $class; /** @var array $columns */ public readonly array $columns; /** @var array */ @@ -16,10 +18,10 @@ class Schema /** * @param class-string $class */ - public function __construct( - public readonly string $class, - ) { - $reflection = new \ReflectionClass($class); + public function __construct(string $class) + { + $this->class = $class; + $reflection = new \ReflectionClass($this->class); $attributes = []; $hydrator = null; foreach ($reflection->getAttributes() as $attribute) { @@ -46,6 +48,11 @@ public function getColumn(string $name): ?AbstractColumn */ public function getFirstAttributeByClass(string $class): ?object { - return current(array_filter($this->attributes, fn($attribute) => $attribute instanceof $class)) ?: null; + foreach ($this->attributes as $attribute) { + if ($attribute instanceof $class) { + return $attribute; + } + } + return null; } } diff --git a/tests/Columns/CollectionColumnTest.php b/tests/Columns/CollectionColumnTest.php new file mode 100644 index 0000000..60b6465 --- /dev/null +++ b/tests/Columns/CollectionColumnTest.php @@ -0,0 +1,226 @@ +push($sub1); + + $collection2 = new TestEntityCollection(); + $collection2->push($sub1); + $collection2->push($sub2); + + return [ + [ + 'value' => null, + 'expected' => null, + ], + [ + 'value' => '[]', + 'expected' => new TestEntityCollection(), + ], + [ + 'value' => '[{"foo": fa}]', + 'expected' => null, + ], + [ + 'value' => false, + 'expected' => null, + ], + [ + 'value' => json_encode([$sub1]), + 'expected' => $collection1, + ], + [ + 'value' => json_encode([$sub1, null, $sub2]), + 'expected' => $collection2, + ], + ]; + } + + /** + * @dataProvider entityCast_dataProvider + */ + public function test_entityCast(mixed $value, ?TestEntityCollection $expected): void + { + $class = new class extends AbstractEntity { + public function __construct( + public ?TestEntityCollection $column = null, + ) {} + }; + $entity = $class::fromArray(['column' => $value]); + $this->compareCollections($expected, $entity->column); + } + + public static function stringCast_dataProvider(): array + { + $collection = new TestStringCollection(); + $collection[] = 'a'; + $collection[] = 'b'; + + return [ + [ + 'value' => null, + 'expected' => null, + ], + [ + 'value' => '[]', + 'expected' => new TestStringCollection(), + ], + [ + 'value' => json_encode(['a', 'b']), + 'expected' => $collection, + ], + ]; + } + + /** + * @dataProvider stringCast_dataProvider + */ + public function test_stringCast(mixed $value, ?TestStringCollection $expected): void + { + $class = new class extends AbstractEntity { + public function __construct( + public ?TestStringCollection $column = null, + ) {} + }; + $entity = $class::fromArray(['column' => $value]); + $this->compareCollections($expected, $entity->column); + } + + public static function entityUncast_dataProvider(): array + { + $sub1 = new TestEntity(str: 'foo'); + $sub2 = new TestEntity(str: 'bar'); + + $collection = new TestEntityCollection(); + $collection->push($sub1); + $collection->push($sub2); + + + return [ + [ + 'value' => null, + 'expected' => null, + ], + [ + 'value' => new TestEntityCollection(), + 'expected' => '[]', + ], + [ + 'value' => $collection, + 'expected' => json_encode([$sub1, $sub2]), + ], + ]; + } + + /** + * @dataProvider entityUncast_dataProvider + */ + public function test_entityUncast(?TestEntityCollection $value, mixed $expected): void + { + $entity = new class($value) extends AbstractEntity { + public function __construct( + public ?TestEntityCollection $column, + ) {} + }; + $actual = $entity->toArray()['column']; + $this->assertSame($expected, $actual); + + $newEntity = $entity::fromArray(['column' => $actual]); + $this->compareCollections($newEntity->column, $value); + } + + public static function stringUncast_dataProvider(): array + { + $collection = new TestStringCollection(); + $collection[] = 'a'; + $collection[] = 'b'; + + return [ + [ + 'value' => null, + 'expected' => null, + ], + [ + 'value' => new TestStringCollection(), + 'expected' => '[]', + ], + [ + 'value' => $collection, + 'expected' => json_encode(['a', 'b']), + ], + ]; + } + + /** + * @dataProvider stringUncast_dataProvider + */ + public function test_stringUncast(?TestStringCollection $value, mixed $expected): void + { + $entity = new class($value) extends AbstractEntity { + public function __construct( + public ?TestStringCollection $column, + ) {} + }; + $actual = $entity->toArray()['column']; + $this->assertSame($expected, $actual); + + $newEntity = $entity::fromArray(['column' => $actual]); + $this->compareCollections($newEntity->column, $value); + } + + public function test_uncastException(): void + { + $sub = new TestEntity(float: INF); + $collection = new TestEntityCollection(); + $collection[] = $sub; + + $entity = new class($collection) extends AbstractEntity { + public function __construct( + public TestEntityCollection $column, + ) {} + }; + $this->expectException(EntityException::class); + $entity->toArray(); + } + + private function compareCollections(\SplDoublyLinkedList|ArrayCollection|null $expected, \SplDoublyLinkedList|ArrayCollection|null $actual): void + { + $compareExpected = $compareActual = null; + if ($expected) { + $compareExpected = []; + foreach ($expected as $value) { + if ($value instanceof AbstractEntity) { + $compareExpected[] = $value->toArray(); + } else { + $compareExpected[] = $value; + } + } + } + if ($actual) { + $compareActual = []; + foreach ($actual as $value) { + if ($value instanceof AbstractEntity) { + $compareActual[] = $value->toArray(); + } else { + $compareActual[] = $value; + } + } + } + $this->assertSame($compareExpected, $compareActual); + } +} \ No newline at end of file diff --git a/tests/Columns/EntityListColumnTest.php b/tests/Columns/EntityListColumnTest.php index 9a8b132..81308b0 100644 --- a/tests/Columns/EntityListColumnTest.php +++ b/tests/Columns/EntityListColumnTest.php @@ -216,12 +216,8 @@ public function __construct( public array $column, ) {} }; - try { - $entity->toArray(); - $this->assertTrue(false); - } catch (EntityException) { - $this->assertTrue(true); - } + $this->expectException(EntityException::class); + $entity->toArray(); } @@ -232,11 +228,7 @@ public function __construct( public TestEntity $column, ) {} }; - try { - $entity->toArray(); - $this->assertTrue(false); - } catch (EntityException) { - $this->assertTrue(true); - } + $this->expectException(EntityException::class); + $entity->toArray(); } } \ No newline at end of file diff --git a/tests/TestStand/TestEntityCollection.php b/tests/TestStand/TestEntityCollection.php new file mode 100644 index 0000000..01299c8 --- /dev/null +++ b/tests/TestStand/TestEntityCollection.php @@ -0,0 +1,11 @@ +