Skip to content

Commit

Permalink
Add CollectionRecipes for describing CRUD behaviour
Browse files Browse the repository at this point in the history
  • Loading branch information
jzaplet committed Mar 16, 2024
1 parent 5eb5e9d commit c0e3f13
Show file tree
Hide file tree
Showing 32 changed files with 636 additions and 437 deletions.
4 changes: 3 additions & 1 deletion config/app.neon
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ services:
- Megio\Http\Kernel\App
- Megio\Storage\Storage
- Megio\Debugger\ResponseFormatter
- Megio\Database\CrudHelper\CrudHelper

- Megio\Database\EntityFinder
- Megio\Collection\RecipeFinder

- Megio\Security\JWT\JWTResolver
- Megio\Security\JWT\ClaimsFormatter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
*/
declare(strict_types=1);

namespace Megio\Database\CrudHelper;
namespace Megio\Collection;

class CrudException extends \Exception
class CollectionException extends \Exception
{
}
38 changes: 38 additions & 0 deletions src/Collection/CollectionPropType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Megio\Collection;

enum CollectionPropType
{
case INVISIBLE;
case SHOW_ONE;
case SHOW_ALL;
case NONE;

/**
* @param \Megio\Collection\ICollectionRecipe $recipe
* @return string[]
*/
public function getPropNames(ICollectionRecipe $recipe): array
{
return match ($this) {
self::INVISIBLE => array_merge(['id'], $recipe->invisibleColumns()),
self::SHOW_ONE => array_merge(['id'], $recipe->showOneColumns()),
self::SHOW_ALL => array_merge(['id'], $recipe->showAllColumns()),
self::NONE => [],
};
}

/**
* @param array{maxLength: int|null, name: string, nullable: bool, type: string}[] $schema
* @param \Megio\Collection\ICollectionRecipe $recipe
* @return array{maxLength: int|null, name: string, nullable: bool, type: string}[] $schema
*/
public function getAllowedPropNames(array $schema, ICollectionRecipe $recipe): array
{
$propNames = $this->getPropNames($recipe);
$props = array_filter($schema, fn($field) => in_array($field['name'], $propNames));

return array_values($props);
}
}
51 changes: 51 additions & 0 deletions src/Collection/CollectionRecipe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);

namespace Megio\Collection;

use Doctrine\ORM\Mapping\Table;
use Megio\Database\Interface\ICrudable;

abstract class CollectionRecipe implements ICollectionRecipe
{
public function invisibleColumns(): array
{
return ['id', 'updatedAt'];
}

/**
* @param \Megio\Collection\CollectionPropType $type
* @return \Megio\Collection\RecipeEntityMetadata
* @throws \Megio\Collection\CollectionException
* @throws \ReflectionException
*/
public final function getEntityMetadata(CollectionPropType $type): RecipeEntityMetadata
{
if (!is_subclass_of($this->source(), ICrudable::class)) {
throw new CollectionException("Entity '{$this->source()}' does not implement ICrudable");
}

$rf = new \ReflectionClass($this->source());
$attr = $rf->getAttributes(Table::class);

if (count($attr) === 0) {
throw new CollectionException("Entity '{$this->source()}' is missing Table attribute");
}

/** @var Table $attrInstance */
$attrInstance = $attr[0]->newInstance();

if ($attrInstance->name === null) {
throw new CollectionException("Entity '{$this->source()}' has Table attribute without name");
}

$tableName = str_replace('`', '', $attrInstance->name);
$metadata = new RecipeEntityMetadata($this, $rf, $type, $tableName);

if (count($metadata->getSchema()['props']) === 1) {
throw new CollectionException("Collection '{$this->name()}' has no visible columns");
}

return $metadata;
}
}
28 changes: 28 additions & 0 deletions src/Collection/ICollectionRecipe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);

namespace Megio\Collection;

interface ICollectionRecipe
{
/** @return class-string */
public function source(): string;

/** @return string */
public function name(): string;

/** @return string[] */
public function invisibleColumns(): array;

/** @return string[] */
public function showOneColumns(): array;

/** @return string[] */
public function showAllColumns(): array;

/**
* @throws \Megio\Collection\CollectionException
* @throws \ReflectionException
*/
public function getEntityMetadata(CollectionPropType $type): RecipeEntityMetadata;
}
58 changes: 58 additions & 0 deletions src/Collection/Mapping/ArrayToEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);

namespace Megio\Collection\Mapping;

use Megio\Collection\CollectionException;
use Megio\Collection\ICollectionRecipe;
use Megio\Collection\RecipeEntityMetadata;
use Megio\Database\Interface\ICrudable;

class ArrayToEntity
{
/**
* @param \Megio\Collection\ICollectionRecipe $recipe
* @param \Megio\Collection\RecipeEntityMetadata $metadata
* @param array<string, int|float|string|bool> $data
* @return \Megio\Database\Interface\ICrudable
* @throws \Megio\Collection\CollectionException
*/
public static function create(ICollectionRecipe $recipe, RecipeEntityMetadata $metadata, array $data): ICrudable
{
$className = $recipe->source();

/** @var \Megio\Database\Interface\ICrudable $entity */
$entity = new $className();

return self::update($metadata, $entity, $data);
}

/**
* @param \Megio\Collection\RecipeEntityMetadata $metadata
* @param \Megio\Database\Interface\ICrudable $entity
* @param array<string, int|float|string|bool> $data
* @return \Megio\Database\Interface\ICrudable
* @throws \Megio\Collection\CollectionException
*/
public static function update(RecipeEntityMetadata $metadata, ICrudable $entity, array $data): ICrudable
{
$ref = $metadata->getReflection();
$methods = array_map(fn($method) => $method->name, $ref->getMethods());

foreach ($data as $key => $value) {
try {
$methodName = 'set' . ucfirst($key);
if (in_array($methodName, $methods)) {
$m = $ref->getMethod($methodName)->name;
$entity->$m($value);
} else {
$ref->getProperty($key)->setValue($entity, $value);
}
} catch (\ReflectionException) {
throw new CollectionException("Property '{$key}' does not exist");
}
}

return $entity;
}
}
139 changes: 139 additions & 0 deletions src/Collection/RecipeEntityMetadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);

namespace Megio\Collection;

use Doctrine\ORM\Mapping\Column;

readonly class RecipeEntityMetadata
{
/**
* @param \Megio\Collection\ICollectionRecipe $recipe
* @param \ReflectionClass<\Megio\Database\Interface\ICrudable> $entityRef
* @param \Megio\Collection\CollectionPropType $type
* @param string $tableName
*/
public function __construct(
private ICollectionRecipe $recipe,
private \ReflectionClass $entityRef,
private CollectionPropType $type,
private string $tableName,
)
{
}

/**
* @return string
*/
public function getTableName(): string
{
return $this->tableName;
}

/**
* @return \ReflectionClass<\Megio\Database\Interface\ICrudable>
*/
public function getReflection(): \ReflectionClass
{
return $this->entityRef;
}

public function getQbSelect(string $qbAlias): string
{
$schema = $this->getFullSchemaReflectedByDoctrine();
$visibleProps = $this->type->getAllowedPropNames($schema, $this->recipe);
return implode(', ', array_map(fn($col) => $qbAlias . '.' . $col['name'], $visibleProps));
}

/**
* @return array{
* meta: array{table: string, invisible: string[]},
* props: array{maxLength: int|null, name: string, nullable: bool, type: string}[]
* }
*/
public function getSchema(): array
{
$schema = $this->getFullSchemaReflectedByDoctrine();
$props = $this->type->getAllowedPropNames($schema, $this->recipe);

return [
'meta' => [
'table' => $this->tableName,
'invisible' => $this->recipe->invisibleColumns()
],
'props' => $this->sortColumns($props)
];
}

/**
* @return array{maxLength: int|null, name: string, nullable: bool, type: string}[]
*/
private function getFullSchemaReflectedByDoctrine(): array
{
$props = [];
foreach ($this->entityRef->getProperties() as $prop) {
$attrs = array_map(fn($attr) => $attr->newInstance(), $prop->getAttributes());

/** @var Column[] $columnAttrs */
$columnAttrs = array_filter($attrs, fn($attr) => $attr instanceof Column);
if (count($columnAttrs) !== 0) {
$attr = array_values($columnAttrs)[0];
$props[] = $this->getColumnMetadata($attr, $prop);
}
}

// move array item with name "id" to first position
$idProp = array_filter($props, fn($prop) => $prop['name'] !== 'id');
return array_merge(array_values(array_filter($props, fn($prop) => $prop['name'] === 'id')), $idProp);
}

/**
* @param \Doctrine\ORM\Mapping\Column $attr
* @param \ReflectionProperty $prop
* @return array{maxLength: int|null, name: string, nullable: bool, type: string}
*/
private function getColumnMetadata(Column $attr, \ReflectionProperty $prop): array
{
$propType = $prop->getType();
$nullable = $attr->nullable;

$type = $attr->type;
if ($type === null) {
$type = $propType instanceof \ReflectionNamedType ? $propType->getName() : $propType ?? '@unknown';
}

$maxLength = $attr->length;
if ($maxLength === null && $type === 'string') {
$maxLength = 255;
}

return [
'name' => $prop->getName(),
'type' => mb_strtolower($type),
'nullable' => $nullable,
'maxLength' => $maxLength
];
}


/**
* @param array{maxLength: int|null, name: string, nullable: bool, type: string}[] $fields
* @return array{maxLength: int|null, name: string, nullable: bool, type: string}[]
*/
private function sortColumns(array $fields): array
{
$associativeFields = [];
foreach ($fields as $field) {
$associativeFields[$field['name']] = $field;
}

$sortedFields = [];
foreach ($this->type->getPropNames($this->recipe) as $name) {
if (array_key_exists($name, $associativeFields)) {
$sortedFields[] = $associativeFields[$name];
}
}

return $sortedFields;
}
}
46 changes: 46 additions & 0 deletions src/Collection/RecipeFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);

namespace Megio\Collection;

use Megio\Helper\Path;
use Nette\Utils\Finder;

class RecipeFinder
{
/** @var ICollectionRecipe[] */
protected array $recipes = [];

public function load(): self
{
$appFiles = Finder::findFiles()->from(Path::appDir() . '/Recipe');
foreach ($appFiles as $file) {
$class = 'App\\Recipe\\' . $file->getBasename('.php');
if (is_subclass_of($class, ICollectionRecipe::class)) {
$this->recipes[] = new $class();
}
}

$vendorFiles = Finder::findFiles()->from(Path::megioVendorDir() . '/src/Recipe');
foreach ($vendorFiles as $file) {
$class = 'Megio\\Recipe\\' . $file->getBasename('.php');
if (is_subclass_of($class, ICollectionRecipe::class)) {
$this->recipes[] = new $class();
}
}

return $this;
}

/** @return ICollectionRecipe[] */
public function getAll(): array
{
return $this->recipes;
}

public function findByName(string $name): ?ICollectionRecipe
{
$recipe = current(array_filter($this->recipes, fn($r) => $r->name() === $name));
return $recipe ?: null;
}
}
Loading

0 comments on commit c0e3f13

Please sign in to comment.