AttributeMetadataFactory.php
PHP
Path: src/Metadata/AttributeMetadataFactory.php
<?php
namespace mini\Metadata;
use ReflectionClass;
use ReflectionProperty;
use mini\I18n\Translatable;
use mini\Mini;
use mini\Metadata\Attributes;
/**
* Builds metadata from PHP class attributes
*
* Scans class and property attributes to construct Metadata instances
* that describe the entity structure. String values (title, description)
* are automatically wrapped in Translatable for i18n support.
*/
class AttributeMetadataFactory
{
/**
* Build metadata from a class using reflection
*
* @param class-string $className Class to build metadata for
* @return Metadata Metadata instance with property metadata
*/
public function forClass(string $className): Metadata
{
$reflection = new ReflectionClass($className);
$sourceFile = $this->getRelativeSourceFile($reflection->getFileName());
$metadata = new Metadata();
// Process class-level attributes
$metadata = $this->applyClassAttributes($reflection, $metadata, $sourceFile);
$properties = [];
// First, process Property attributes on the class itself (property-less metadata)
foreach ($reflection->getAttributes(Attributes\Property::class) as $attribute) {
$prop = $attribute->newInstance();
$propMetadata = $this->buildPropertyMetadata($prop, $sourceFile);
if ($propMetadata !== null) {
$properties[$prop->name] = $propMetadata;
}
}
// Then, process actual properties
$refs = [];
foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
$propMetadata = $this->buildPropertyMetadataFromProperty($property, $sourceFile);
if ($propMetadata !== null) {
$properties[$property->getName()] = $propMetadata;
}
// Check for class reference (Ref attribute or type hint)
$refClass = $this->getPropertyRefClass($property);
if ($refClass !== null) {
$refs[$property->getName()] = $refClass;
}
}
// Add property metadata first (this clones)
if (!empty($properties)) {
$metadata = $metadata->properties($properties);
}
// Then add refs (each call clones, so do this last)
foreach ($refs as $propName => $refClass) {
$metadata = $metadata->ref($propName, $refClass);
}
return $metadata;
}
/**
* Get the class reference for a property
*
* Returns the class from Ref attribute if present, otherwise
* extracts from the property's type hint if it's a class.
*
* @return class-string|null
*/
private function getPropertyRefClass(ReflectionProperty $property): ?string
{
// Check for explicit Ref attribute first
$refAttrs = $property->getAttributes(Attributes\Ref::class);
if (!empty($refAttrs)) {
return $refAttrs[0]->newInstance()->class;
}
// Check type hint for class reference
$type = $property->getType();
if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) {
return null;
}
$typeName = $type->getName();
// Only return if it's a class (not interface for now, could be expanded)
if (class_exists($typeName) || interface_exists($typeName)) {
return $typeName;
}
return null;
}
/**
* Get source file path relative to project root
*/
private function getRelativeSourceFile(string $absolutePath): string
{
$projectRoot = Mini::$mini->root;
return str_replace($projectRoot . '/', '', $absolutePath);
}
/**
* Wrap a string value in Translatable with the source file context
*
* If the value is already a Stringable (e.g., Translatable), returns it as-is.
*/
private function translatable(\Stringable|string $text, string $sourceFile): \Stringable
{
if ($text instanceof \Stringable) {
return $text;
}
return new Translatable($text, [], $sourceFile);
}
/**
* Wrap a value in Translatable only if it's a string
*
* Non-string values (int, float, bool, null, array) pass through unchanged.
*/
private function translatableIfString(mixed $value, string $sourceFile): mixed
{
if (is_string($value)) {
return new Translatable($value, [], $sourceFile);
}
return $value;
}
/**
* Wrap string values in an array with Translatable
*
* Non-string values pass through unchanged.
*/
private function translatableArray(array $values, string $sourceFile): array
{
return array_map(
fn($v) => $this->translatableIfString($v, $sourceFile),
$values
);
}
/**
* Apply class-level attributes to metadata
*
* @param ReflectionClass $reflection Class reflection
* @param Metadata $metadata Base metadata
* @param string $sourceFile Source file for translations
* @return Metadata Metadata with class attributes applied
*/
private function applyClassAttributes(ReflectionClass $reflection, Metadata $metadata, string $sourceFile): Metadata
{
foreach ($reflection->getAttributes() as $attribute) {
// Only process metadata attributes
if (!str_starts_with($attribute->getName(), 'mini\\Metadata\\Attributes\\')) {
continue;
}
$instance = $attribute->newInstance();
$metadata = $this->applyAttribute($metadata, $instance, $sourceFile);
}
return $metadata;
}
/**
* Build metadata from a Property attribute
*
* @param Attributes\Property $prop Property attribute instance
* @param string $sourceFile Source file for translations
* @return Metadata|null Property metadata
*/
private function buildPropertyMetadata(Attributes\Property $prop, string $sourceFile): ?Metadata
{
$metadata = new Metadata();
if ($prop->title !== null) {
$metadata = $metadata->title($this->translatable($prop->title, $sourceFile));
}
if ($prop->description !== null) {
$metadata = $metadata->description($this->translatable($prop->description, $sourceFile));
}
if ($prop->default !== null) {
$metadata = $metadata->default($this->translatableIfString($prop->default, $sourceFile));
}
if (!empty($prop->examples)) {
$metadata = $metadata->examples(...$this->translatableArray($prop->examples, $sourceFile));
}
if ($prop->readOnly !== null) {
$metadata = $metadata->readOnly($prop->readOnly);
}
if ($prop->writeOnly !== null) {
$metadata = $metadata->writeOnly($prop->writeOnly);
}
if ($prop->deprecated !== null) {
$metadata = $metadata->deprecated($prop->deprecated);
}
if ($prop->format !== null) {
$metadata = $metadata->format($prop->format);
}
return $metadata;
}
/**
* Build metadata for a single property from its attributes
*
* @param ReflectionProperty $property Property to build metadata for
* @param string $sourceFile Source file for translations
* @return Metadata|null Property metadata, or null if no metadata attributes
*/
private function buildPropertyMetadataFromProperty(ReflectionProperty $property, string $sourceFile): ?Metadata
{
$attributes = $property->getAttributes();
if (empty($attributes)) {
return null;
}
$metadata = new Metadata();
$hasMetadata = false;
foreach ($attributes as $attribute) {
// Skip non-metadata attributes (e.g., Validator, Tables attributes)
if (!str_starts_with($attribute->getName(), 'mini\\Metadata\\Attributes\\')) {
continue;
}
// Skip Ref attribute - it's handled separately for class references
if ($attribute->getName() === Attributes\Ref::class) {
continue;
}
$hasMetadata = true;
$instance = $attribute->newInstance();
$metadata = $this->applyAttribute($metadata, $instance, $sourceFile);
}
return $hasMetadata ? $metadata : null;
}
/**
* Apply a metadata attribute to a metadata instance
*
* @param Metadata $metadata Base metadata
* @param object $attribute Attribute instance
* @param string $sourceFile Source file for translations
* @return Metadata Metadata with attribute applied
*/
private function applyAttribute(Metadata $metadata, object $attribute, string $sourceFile): Metadata
{
return match(get_class($attribute)) {
Attributes\Title::class => $metadata->title($this->translatable($attribute->title, $sourceFile)),
Attributes\Description::class => $metadata->description($this->translatable($attribute->description, $sourceFile)),
Attributes\Examples::class => $metadata->examples(...$this->translatableArray($attribute->examples, $sourceFile)),
Attributes\DefaultValue::class => $metadata->default($this->translatableIfString($attribute->default, $sourceFile)),
Attributes\IsReadOnly::class => $metadata->readOnly($attribute->value),
Attributes\IsWriteOnly::class => $metadata->writeOnly($attribute->value),
Attributes\IsDeprecated::class => $metadata->deprecated($attribute->value),
Attributes\MetaFormat::class => $metadata->format($attribute->format),
default => $metadata
};
}
}