Skip to content

Commit

Permalink
Add Collection column type
Browse files Browse the repository at this point in the history
  • Loading branch information
compositephp committed Nov 25, 2023
1 parent 9736dee commit fc58f17
Show file tree
Hide file tree
Showing 11 changed files with 409 additions and 108 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
137 changes: 79 additions & 58 deletions src/ColumnBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Composite\Entity;

use Composite\Entity\Columns;
use Composite\Entity\Exceptions\EntityException;
use Ramsey\Uuid\UuidInterface;

Expand Down Expand Up @@ -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<class-string, object> $propertyAttributes */
$propertyAttributes = [];
foreach ($property->getAttributes() as $attribute) {
Expand All @@ -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;
Expand All @@ -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<class-string, object> $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];
}
}
1 change: 1 addition & 0 deletions src/Columns/AbstractColumn.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions src/Columns/CollectionColumn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php declare(strict_types=1);

namespace Composite\Entity\Columns;

use Composite\Entity\AbstractEntity;
use Composite\Entity\Exceptions\EntityException;

class CollectionColumn extends AbstractColumn
{
/**
* @throws EntityException
*/
public function cast(mixed $dbValue): \ArrayAccess
{
if (is_string($dbValue)) {
try {
$dbValue = (array)\json_decode($dbValue, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new EntityException($e->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);
}
}
}
3 changes: 3 additions & 0 deletions src/Columns/EntityColumn.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 4 additions & 32 deletions src/Columns/EntityListColumn.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,6 @@

class EntityListColumn extends AbstractColumn
{
private readonly ?string $keyColumn;
/**
* @param array<string, object> $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<AbstractEntity>
* @throws EntityException
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
17 changes: 12 additions & 5 deletions src/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

class Schema
{
/** @var class-string<AbstractEntity> $class */
public readonly string $class;
/** @var array<string, AbstractColumn> $columns */
public readonly array $columns;
/** @var array<object> */
Expand All @@ -16,10 +18,10 @@ class Schema
/**
* @param class-string<AbstractEntity> $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) {
Expand All @@ -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;
}
}
Loading

0 comments on commit fc58f17

Please sign in to comment.