fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled

This commit is contained in:
2025-11-24 21:28:25 +01:00
parent 4eb7134853
commit 77abc65cd7
1327 changed files with 91915 additions and 9909 deletions

View File

@@ -11,7 +11,10 @@ use App\Framework\Router\Result\JsonResult;
/**
* Admin API Handler
*
* Handles auto-generated API endpoints for admin resources
* Handles auto-generated API endpoints for admin resources.
*
* Note: POST/PUT/DELETE operations should use CommandBus instead.
* This handler only provides GET operations (list and get).
*/
final readonly class AdminApiHandler
{
@@ -36,8 +39,8 @@ final readonly class AdminApiHandler
$data = $repository->findAll($filters);
// Apply sorting if requested
if ($sortBy && method_exists($repository, 'sortBy')) {
$data = $repository->sortBy($data, $sortBy, $sortDir);
if ($sortBy) {
$data = $this->sortData($data, $sortBy, $sortDir);
}
// Apply pagination
@@ -84,85 +87,89 @@ final readonly class AdminApiHandler
]);
}
public function handleCreate(
HttpRequest $request,
mixed $repository
): JsonResult {
$data = $request->parsedBody->toArray();
try {
$item = $repository->create($data);
$responseData = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
return new JsonResult([
'success' => true,
'data' => $responseData,
'message' => 'Resource created successfully',
], Status::CREATED);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
/**
* Sort array data by column
*
* @param array $data
* @param string $sortBy
* @param string $sortDir
* @return array
*/
private function sortData(array $data, string $sortBy, string $sortDir): array
{
if (empty($data)) {
return $data;
}
}
public function handleUpdate(
string $id,
HttpRequest $request,
mixed $repository
): JsonResult {
$data = $request->parsedBody->toArray();
// Normalize sort direction
$sortDir = strtolower($sortDir) === 'desc' ? 'desc' : 'asc';
try {
$item = $repository->update($id, $data);
// Convert objects to arrays for sorting
$arrayData = array_map(
fn ($item) => method_exists($item, 'toArray') ? $item->toArray() : (array) $item,
$data
);
if ($item === null) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',
], Status::NOT_FOUND);
usort($arrayData, function ($a, $b) use ($sortBy, $sortDir) {
$valueA = $this->getNestedValue($a, $sortBy);
$valueB = $this->getNestedValue($b, $sortBy);
// Handle null values
if ($valueA === null && $valueB === null) {
return 0;
}
if ($valueA === null) {
return $sortDir === 'asc' ? -1 : 1;
}
if ($valueB === null) {
return $sortDir === 'asc' ? 1 : -1;
}
$responseData = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
return new JsonResult([
'success' => true,
'data' => $responseData,
'message' => 'Resource updated successfully',
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
public function handleDelete(
string $id,
mixed $repository
): JsonResult {
try {
$deleted = $repository->delete($id);
if (! $deleted) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',
], Status::NOT_FOUND);
// Compare values
if (is_numeric($valueA) && is_numeric($valueB)) {
$result = $valueA <=> $valueB;
} else {
$result = strcmp((string) $valueA, (string) $valueB);
}
return new JsonResult([
'success' => true,
'message' => 'Resource deleted successfully',
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
return $sortDir === 'desc' ? -$result : $result;
});
// Convert back to original objects if needed
if (method_exists($data[0], 'toArray')) {
// If original data was objects, we need to reconstruct them
// For now, return array data as API typically returns arrays
return $arrayData;
}
return $arrayData;
}
/**
* Get nested value from array using dot notation
*
* @param array $data
* @param string $key
* @return mixed
*/
private function getNestedValue(array $data, string $key): mixed
{
if (isset($data[$key])) {
return $data[$key];
}
// Support dot notation for nested keys
$keys = explode('.', $key);
$value = $data;
foreach ($keys as $k) {
if (!is_array($value) || !isset($value[$k])) {
return null;
}
$value = $value[$k];
}
return $value;
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Admin;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\FormBuilder;
@@ -17,21 +19,24 @@ use App\Framework\View\Table\Table;
*/
final readonly class AdminPageRenderer
{
public function __construct()
{
public function __construct(
private ComponentRegistry $componentRegistry
) {
}
public function renderIndex(
string $resource,
Table $table,
string $title,
array $actions = []
array $actions = [],
array $filterOptions = []
): ViewResult {
$data = [
'title' => $title,
'resource' => $resource,
'table' => $table->render(),
'actions' => $actions,
'filter_options' => $filterOptions,
];
return new ViewResult(
@@ -78,4 +83,37 @@ final readonly class AdminPageRenderer
data: $pageData
);
}
/**
* Render LiveComponent form in admin layout
*
* @param string $resource Resource name (e.g., 'assets', 'contents')
* @param LiveComponentContract $component LiveComponent instance
* @param string $title Page title
* @param string|null $subtitle Optional subtitle
* @return ViewResult View result with rendered form
*/
public function renderLiveForm(
string $resource,
LiveComponentContract $component,
string $title,
?string $subtitle = null
): ViewResult {
// Render component with wrapper (includes data-live-component attributes, CSRF, etc.)
$componentHtml = $this->componentRegistry->renderWithWrapper($component);
$data = [
'title' => $title,
'subtitle' => $subtitle,
'resource' => $resource,
'component' => $componentHtml,
'is_live_component' => true,
];
return new ViewResult(
template: 'admin-form',
metaData: new MetaData($title, "Admin - {$title}"),
data: $data
);
}
}

View File

@@ -47,6 +47,9 @@ final readonly class CampaignCrudController
/**
* Show campaigns index
*
* Uses CrudService to render index view with table.
* Data should be an array of arrays (not entities).
*/
#[Route(path: '/admin/campaigns', method: Method::GET)]
public function index(Request $request): ViewResult
@@ -54,12 +57,18 @@ final readonly class CampaignCrudController
// Fetch campaigns from repository
$campaigns = $this->campaignRepository->findAll();
// Convert entities to array format for table
$campaignData = array_map(
fn ($campaign) => $campaign->toArray(),
$campaigns
);
// Delegate rendering to CrudService
// CrudService handles table creation and page rendering automatically
return $this->crudService->renderIndex(
config: $this->config,
items: $campaigns,
request: $request,
pagination: null // Add pagination if needed
items: $campaignData,
request: $request
);
}
@@ -207,20 +216,21 @@ final readonly class CampaignCrudController
/**
* Get campaign form fields configuration
*
* Form fields format matches AdminFormConfig requirements.
* Each field is an associative array with type, name, label, etc.
*/
private function getCampaignFormFields(): array
{
return [
[
'artist_name' => [
'type' => 'text',
'name' => 'artist_name',
'label' => 'Artist Name',
'required' => true,
'placeholder' => 'Enter artist name',
],
[
'campaign_type' => [
'type' => 'select',
'name' => 'campaign_type',
'label' => 'Campaign Type',
'required' => true,
'options' => [
@@ -229,23 +239,20 @@ final readonly class CampaignCrudController
['value' => 'tour', 'label' => 'Tour Campaign'],
],
],
[
'spotify_uri' => [
'type' => 'text',
'name' => 'spotify_uri',
'label' => 'Spotify URI',
'required' => true,
'placeholder' => 'spotify:album:...',
'help' => 'Full Spotify URI for the album or track',
],
[
'release_date' => [
'type' => 'date',
'name' => 'release_date',
'label' => 'Release Date',
'required' => false,
],
[
'status' => [
'type' => 'select',
'name' => 'status',
'label' => 'Status',
'required' => true,
'options' => [
@@ -254,9 +261,8 @@ final readonly class CampaignCrudController
['value' => 'archived', 'label' => 'Archived'],
],
],
[
'description' => [
'type' => 'textarea',
'name' => 'description',
'label' => 'Description',
'required' => false,
'placeholder' => 'Campaign description (optional)',

View File

@@ -5,9 +5,12 @@ declare(strict_types=1);
namespace App\Framework\Admin\Factories;
use App\Framework\Admin\ValueObjects\AdminTableConfig;
use App\Framework\View\Table\CellType;
use App\Framework\View\Table\Formatters;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\ValueObjects\AdminTableAttribute;
use App\Framework\View\ValueObjects\BulkOperationAttribute;
/**
* Admin Table Factory
@@ -20,6 +23,17 @@ final readonly class AdminTableFactory
{
// Build column definitions from config
$columnDefs = [];
// Add checkbox column if bulk operations are enabled
if ($config->bulkOperations) {
$columnDefs['_checkbox'] = [
'header' => '<input type="checkbox" class="bulk-select-all" ' . BulkOperationAttribute::BULK_SELECT_ALL->value() . '>',
'sortable' => false,
'class' => 'bulk-checkbox-column',
'type' => CellType::HTML,
];
}
foreach ($config->columns as $key => $columnConfig) {
$columnDefs[$key] = [
'header' => $columnConfig['label'],
@@ -29,24 +43,97 @@ final readonly class AdminTableFactory
];
}
// Build table options with data attributes for JavaScript
$options = TableOptions::admin()
->withId($config->resource . 'Table')
->withDataAttributes([
'resource' => $config->resource,
'api-endpoint' => $config->getApiEndpoint(),
'sortable' => $config->sortable ? 'true' : 'false',
'searchable' => $config->searchable ? 'true' : 'false',
'paginated' => $config->paginated ? 'true' : 'false',
'per-page' => (string) $config->perPage,
]);
// Add actions column if row actions are enabled
if ($config->rowActions) {
$columnDefs['_actions'] = [
'header' => 'Actions',
'sortable' => false,
'class' => 'admin-table-actions',
'type' => CellType::HTML,
];
}
// Use existing Table::fromArray
return Table::fromArray(
// Add checkbox data to each row if bulk operations are enabled
if ($config->bulkOperations) {
foreach ($data as &$row) {
if (!isset($row['id'])) {
continue; // Skip rows without ID
}
$row['_checkbox'] = '<input type="checkbox" class="bulk-select-item" ' . BulkOperationAttribute::BULK_ITEM_ID->value() . '="' . htmlspecialchars((string) $row['id'], ENT_QUOTES) . '">';
}
unset($row); // Break reference
}
// Add actions data to each row if row actions are enabled
if ($config->rowActions) {
foreach ($data as &$row) {
if (!isset($row['id'])) {
continue; // Skip rows without ID
}
$rowId = htmlspecialchars((string) $row['id'], ENT_QUOTES);
$actionsHtml = '<div class="admin-table__actions">';
// Add edit button with SVG icon (matching AdminSidebar pattern)
$editUrl = str_replace('{id}', $rowId, $config->editUrlTemplate ?? '/admin/' . $config->resource . '/{id}/edit');
$actionsHtml .= '<a href="' . htmlspecialchars($editUrl, ENT_QUOTES) . '" class="admin-table__action" title="Edit" aria-label="Edit">';
$actionsHtml .= '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">';
$actionsHtml .= '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>';
$actionsHtml .= '</svg>';
$actionsHtml .= '</a>';
// Add delete button with SVG icon
$actionsHtml .= '<button type="button" class="admin-table__action admin-table__action--danger" data-delete-item="' . $rowId . '" title="Delete" aria-label="Delete">';
$actionsHtml .= '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">';
$actionsHtml .= '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>';
$actionsHtml .= '</svg>';
$actionsHtml .= '</button>';
$actionsHtml .= '</div>';
$row['_actions'] = $actionsHtml;
}
unset($row); // Break reference
}
// Build table options with data attributes for JavaScript
$tableAttributes = [
AdminTableAttribute::RESOURCE->value() => $config->resource,
AdminTableAttribute::API_ENDPOINT->value() => $config->getApiEndpoint(),
AdminTableAttribute::SORTABLE->value() => $config->sortable ? 'true' : 'false',
AdminTableAttribute::SEARCHABLE->value() => $config->searchable ? 'true' : 'false',
AdminTableAttribute::PAGINATED->value() => $config->paginated ? 'true' : 'false',
AdminTableAttribute::PER_PAGE->value() => (string) $config->perPage,
];
if ($config->bulkOperations) {
$tableAttributes[BulkOperationAttribute::BULK_OPERATIONS->value()] = 'true';
$tableAttributes[BulkOperationAttribute::BULK_ACTION->value()] = json_encode($config->bulkActions, JSON_UNESCAPED_SLASHES);
}
$options = new TableOptions(
striped: true,
bordered: true,
hover: true,
responsive: true,
emptyMessage: 'Keine Daten verfügbar',
tableAttributes: $tableAttributes
);
// Use existing Table::fromArray with id parameter
$table = Table::fromArray(
data: $data,
columnDefs: $columnDefs,
options: $options
);
// Set id using reflection or create new instance
// Since Table is readonly, we need to create a new instance with id
return new Table(
columns: $table->columns,
rows: $table->rows,
cssClass: $table->cssClass ?? 'admin-table',
options: $table->options,
id: $config->resource . 'Table'
);
}
private function getFormatter(?string $type): mixed
@@ -58,6 +145,7 @@ final readonly class AdminTableFactory
'currency' => new Formatters\CurrencyFormatter(),
'number' => new Formatters\NumberFormatter(),
'masked' => new Formatters\MaskedFormatter(),
'preview' => new Formatters\PreviewFormatter(),
default => null,
};
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Factories;
/**
* Repository Adapter Factory
*
* Creates standardized repository adapters for AdminApiHandler
* that convert domain services/repositories to a common interface.
*/
final readonly class RepositoryAdapterFactory
{
/**
* Create a repository adapter from a service with findAll and findById methods
*
* @param object $service The service or repository to adapt
* @param \Closure(array): array|null $enricher Optional function to enrich each item
* @return object Repository adapter with findAll() and findById() methods
*/
public function createFromService(
object $service,
?\Closure $enricher = null
): object {
return new class($service, $enricher) {
public function __construct(
private readonly object $service,
private readonly ?\Closure $enricher
) {
}
public function findAll(array $filters = []): array
{
// Try to call findAll on the service
if (!method_exists($this->service, 'findAll')) {
throw new \RuntimeException('Service does not have findAll() method');
}
// Try with filters first, fallback to without filters
try {
$data = $this->service->findAll($filters);
} catch (\TypeError|\ArgumentCountError) {
// Service doesn't accept filters parameter
$data = $this->service->findAll();
}
// Convert to arrays if needed
$arrayData = array_map(
fn ($item) => method_exists($item, 'toArray') ? $item->toArray() : (array) $item,
$data
);
// Apply enricher if provided
if ($this->enricher !== null) {
$arrayData = array_map($this->enricher, $arrayData);
}
// Apply search filter if provided
if (isset($filters['search']) && $filters['search']) {
$search = strtolower($filters['search']);
$arrayData = array_filter($arrayData, function ($item) use ($search) {
// Search in all string values
foreach ($item as $value) {
if (is_string($value) && str_contains(strtolower($value), $search)) {
return true;
}
}
return false;
});
$arrayData = array_values($arrayData);
}
return $arrayData;
}
public function findById(string $id): ?array
{
if (!method_exists($this->service, 'findById')) {
return null;
}
try {
$item = $this->service->findById($id);
if ($item === null) {
return null;
}
$data = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
// Apply enricher if provided
if ($this->enricher !== null) {
$data = ($this->enricher)($data);
}
return $data;
} catch (\Throwable) {
return null;
}
}
};
}
/**
* Create a repository adapter from a custom data provider
*
* @param \Closure(array): array $dataProvider Function that returns all data
* @param \Closure(string): array|null $itemProvider Optional function to get single item
* @param \Closure(array): array|null $enricher Optional function to enrich each item
* @return object Repository adapter with findAll() and findById() methods
*/
public function createFromProvider(
\Closure $dataProvider,
?\Closure $itemProvider = null,
?\Closure $enricher = null
): object {
return new class($dataProvider, $itemProvider, $enricher) {
public function __construct(
private readonly \Closure $dataProvider,
private readonly ?\Closure $itemProvider,
private readonly ?\Closure $enricher
) {
}
public function findAll(array $filters = []): array
{
$data = ($this->dataProvider)($filters);
// Ensure array format
$arrayData = array_map(
fn ($item) => is_array($item) ? $item : (method_exists($item, 'toArray') ? $item->toArray() : (array) $item),
$data
);
// Apply enricher if provided
if ($this->enricher !== null) {
$arrayData = array_map($this->enricher, $arrayData);
}
// Apply search filter if provided
if (isset($filters['search']) && $filters['search']) {
$search = strtolower($filters['search']);
$arrayData = array_filter($arrayData, function ($item) use ($search) {
foreach ($item as $value) {
if (is_string($value) && str_contains(strtolower($value), $search)) {
return true;
}
}
return false;
});
$arrayData = array_values($arrayData);
}
return $arrayData;
}
public function findById(string $id): ?array
{
if ($this->itemProvider === null) {
return null;
}
try {
$item = ($this->itemProvider)($id);
if ($item === null) {
return null;
}
$data = is_array($item) ? $item : (method_exists($item, 'toArray') ? $item->toArray() : (array) $item);
// Apply enricher if provided
if ($this->enricher !== null) {
$data = ($this->enricher)($data);
}
return $data;
} catch (\Throwable) {
return null;
}
}
};
}
}

View File

@@ -6,6 +6,9 @@ namespace App\Framework\Admin\FormFields\Components;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\View\ValueObjects\FormElement;
use App\Framework\View\ValueObjects\HtmlAttributes;
use App\Framework\View\ValueObjects\StandardHtmlElement;
use App\Framework\View\ValueObjects\TagName;
/**
* Field Wrapper Component
@@ -19,16 +22,24 @@ final readonly class FieldWrapper
*/
public function wrap(string $content, FieldMetadata $metadata): string
{
$label = FormElement::create('label', ['for' => $metadata->name], $metadata->label);
$label = FormElement::label($metadata->label, $metadata->name);
$html = $label . $content;
$html = (string) $label . $content;
if ($metadata->help !== null) {
$help = FormElement::create('small', ['class' => 'form-text text-muted'], $metadata->help);
$html .= $help;
$help = StandardHtmlElement::create(
TagName::SMALL,
HtmlAttributes::fromArray(['class' => 'form-text text-muted'])
)->withContent($metadata->help);
$html .= (string) $help;
}
return FormElement::create('div', ['class' => 'form-group'], $html);
$div = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::fromArray(['class' => 'form-group'])
)->withContent($html);
return (string) $div;
}
/**
@@ -39,10 +50,18 @@ final readonly class FieldWrapper
$html = $content;
if ($metadata->help !== null) {
$help = FormElement::create('small', ['class' => 'form-text text-muted'], $metadata->help);
$html .= $help;
$help = StandardHtmlElement::create(
TagName::SMALL,
HtmlAttributes::fromArray(['class' => 'form-text text-muted'])
)->withContent($metadata->help);
$html .= (string) $help;
}
return FormElement::create('div', ['class' => 'form-group'], $html);
$div = StandardHtmlElement::create(
TagName::DIV,
HtmlAttributes::fromArray(['class' => 'form-group'])
)->withContent($html);
return (string) $div;
}
}

View File

@@ -37,7 +37,7 @@ final readonly class CheckboxField implements FormField
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
attributes: FieldAttributes::create(
name: $name,
id: $name,
class: 'form-check-input',

View File

@@ -38,7 +38,7 @@ final readonly class DateTimeField implements FormField
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
attributes: FieldAttributes::create(
name: $name,
id: $name,
required: $required
@@ -59,9 +59,9 @@ final readonly class DateTimeField implements FormField
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
$wrapped = $this->wrapper->wrap((string) $input, $this->metadata);
return $form->addElement($wrapped);
return $form->addRawHtml($wrapped);
}
public function getName(): string

View File

@@ -36,7 +36,7 @@ final readonly class EmailField implements FormField
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
attributes: FieldAttributes::create(
name: $name,
id: $name,
required: $required,
@@ -57,9 +57,9 @@ final readonly class EmailField implements FormField
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
$wrapped = $this->wrapper->wrap((string) $input, $this->metadata);
return $form->addElement($wrapped);
return $form->addRawHtml($wrapped);
}
public function getName(): string

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* File Input Field
*
* File upload field using composition
*/
final readonly class FileField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private ?string $accept = null
) {
}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $help = null,
?string $accept = null
): self {
$attributes = FieldAttributes::create(
name: $name,
id: $name,
required: $required
);
if ($accept !== null) {
$attributes = $attributes->withAdditional(['accept' => $accept]);
}
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: $attributes,
wrapper: new FieldWrapper(),
value: $value,
accept: $accept
);
}
public function render(FormBuilder $form): FormBuilder
{
$input = FormElement::fileInput($this->attributes->name())
->withId($this->attributes->id())
->withClass($this->attributes->class());
if ($this->attributes->required()) {
$input = $input->withRequired();
}
if ($this->accept !== null) {
$input = $input->withAttribute('accept', $this->accept);
}
// Add any additional attributes (skip standard ones already set)
$allAttrs = $this->attributes->toHtmlAttributes();
$standardAttrs = ['name', 'id', 'class', 'required'];
foreach ($allAttrs->toArray() as $name => $value) {
if (!in_array($name, $standardAttrs, true) && $value !== null) {
$input = $input->withAttribute($name, $value);
}
}
$wrapped = $this->wrapper->wrap((string) $input, $this->metadata);
return $form->addRawHtml($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -30,7 +30,7 @@ final readonly class HiddenField implements FormField
): self {
return new self(
metadata: new FieldMetadata($name, ''),
attributes: new FieldAttributes(
attributes: FieldAttributes::create(
name: $name,
id: $name,
class: ''

View File

@@ -42,7 +42,7 @@ final readonly class NumberField implements FormField
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
attributes: FieldAttributes::create(
name: $name,
id: $name,
required: $required,
@@ -80,9 +80,9 @@ final readonly class NumberField implements FormField
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
$wrapped = $this->wrapper->wrap((string) $input, $this->metadata);
return $form->addElement($wrapped);
return $form->addRawHtml($wrapped);
}
public function getName(): string

View File

@@ -39,7 +39,7 @@ final readonly class SelectField implements FormField
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
attributes: FieldAttributes::create(
name: $name,
id: $name,
required: $required
@@ -52,14 +52,13 @@ final readonly class SelectField implements FormField
public function render(FormBuilder $form): FormBuilder
{
$attrArray = $this->attributes->toArray();
$optionsHtml = $this->options->toHtml((string) $this->value);
$select = FormElement::create('select', $attrArray, $optionsHtml);
$wrapped = $this->wrapper->wrap($select, $this->metadata);
// Create select element using FormElement::create
$select = FormElement::create('select', $this->attributes->toArray(), $optionsHtml);
$wrapped = $this->wrapper->wrap((string) $select, $this->metadata);
return $form->addElement($wrapped);
return $form->addRawHtml($wrapped);
}
public function getName(): string

View File

@@ -36,7 +36,7 @@ final readonly class TextField implements FormField
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
attributes: FieldAttributes::create(
name: $name,
id: $name,
required: $required,
@@ -57,10 +57,21 @@ final readonly class TextField implements FormField
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
$input = FormElement::textInput($this->attributes->name(), $this->value !== null ? (string) $this->value : '')
->withId($this->attributes->id())
->withClass($this->attributes->class());
return $form->addElement($wrapped);
if ($this->attributes->required()) {
$input = $input->withRequired();
}
if ($this->attributes->placeholder() !== null) {
$input = $input->withAttribute('placeholder', $this->attributes->placeholder());
}
$wrapped = $this->wrapper->wrap((string) $input, $this->metadata);
return $form->addRawHtml($wrapped);
}
public function getName(): string

View File

@@ -38,7 +38,7 @@ final readonly class TextareaField implements FormField
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
attributes: FieldAttributes::create(
name: $name,
id: $name,
required: $required,
@@ -58,9 +58,9 @@ final readonly class TextareaField implements FormField
$content = $this->value !== null ? htmlspecialchars((string) $this->value) : '';
$textarea = FormElement::create('textarea', $attrArray, $content);
$wrapped = $this->wrapper->wrap($textarea, $this->metadata);
$wrapped = $this->wrapper->wrap((string) $textarea, $this->metadata);
return $form->addElement($wrapped);
return $form->addRawHtml($wrapped);
}
public function getName(): string

View File

@@ -7,6 +7,7 @@ namespace App\Framework\Admin\FormFields;
use App\Framework\Admin\FormFields\Fields\CheckboxField;
use App\Framework\Admin\FormFields\Fields\DateTimeField;
use App\Framework\Admin\FormFields\Fields\EmailField;
use App\Framework\Admin\FormFields\Fields\FileField;
use App\Framework\Admin\FormFields\Fields\HiddenField;
use App\Framework\Admin\FormFields\Fields\NumberField;
use App\Framework\Admin\FormFields\Fields\SelectField;
@@ -124,6 +125,15 @@ final readonly class FormFieldFactory
value: $config['value'] ?? null
),
'file' => FileField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
help: $config['help'] ?? null,
accept: $config['accept'] ?? null
),
default => throw FrameworkException::create(
ErrorCode::VAL_INVALID_INPUT,
"Unsupported field type: {$type}"

View File

@@ -4,65 +4,121 @@ declare(strict_types=1);
namespace App\Framework\Admin\FormFields\ValueObjects;
use App\Framework\View\ValueObjects\HtmlAttributes;
/**
* Field Attributes Value Object
*
* Holds HTML attributes for form fields
* Holds HTML attributes for form fields.
* Internally uses HtmlAttributes for consistency and type-safety.
*/
final readonly class FieldAttributes
{
/**
* @param array<string, string> $additional
*/
public function __construct(
public string $name,
public string $id,
public string $class = 'form-control',
public bool $required = false,
public ?string $placeholder = null,
public array $additional = []
private HtmlAttributes $attributes
) {
}
/**
* Convert attributes to array for rendering
* Create FieldAttributes with standard form field attributes
*
* @return array<string, string>
* @param array<string, string> $additional Additional attributes
*/
public function toArray(): array
{
$attrs = [
'name' => $this->name,
'id' => $this->id,
'class' => $this->class,
...$this->additional,
];
public static function create(
string $name,
string $id,
string $class = 'form-control',
bool $required = false,
?string $placeholder = null,
array $additional = []
): self {
$attrs = HtmlAttributes::empty()
->withName($name)
->withId($id)
->withClass($class);
if ($this->required) {
$attrs['required'] = 'required';
if ($required) {
$attrs = $attrs->withRequired();
}
if ($this->placeholder !== null) {
$attrs['placeholder'] = $this->placeholder;
if ($placeholder !== null) {
$attrs = $attrs->with('placeholder', $placeholder);
}
return $attrs;
if (!empty($additional)) {
$attrs = $attrs->withMany($additional);
}
return new self($attrs);
}
/**
* Create with additional attributes
*
* @param array<string, string> $attrs
* @param array<string, string|null> $attrs Additional attributes to add
*/
public function withAdditional(array $attrs): self
{
return new self(
name: $this->name,
id: $this->id,
class: $this->class,
required: $this->required,
placeholder: $this->placeholder,
additional: [...$this->additional, ...$attrs]
);
return new self($this->attributes->withMany($attrs));
}
/**
* Get the underlying HtmlAttributes object
*/
public function toHtmlAttributes(): HtmlAttributes
{
return $this->attributes;
}
/**
* Convert attributes to array for rendering
*
* @return array<string, string|null>
*/
public function toArray(): array
{
return $this->attributes->toArray();
}
/**
* Get attribute value
*/
public function get(string $name): ?string
{
return $this->attributes->get($name);
}
/**
* Check if attribute exists
*/
public function has(string $name): bool
{
return $this->attributes->has($name);
}
// Convenience methods for common attributes
public function name(): string
{
return $this->attributes->get('name') ?? '';
}
public function id(): string
{
return $this->attributes->get('id') ?? '';
}
public function class(): string
{
return $this->attributes->get('class') ?? '';
}
public function required(): bool
{
return $this->attributes->isFlag('required');
}
public function placeholder(): ?string
{
return $this->attributes->get('placeholder');
}
}

View File

@@ -4,14 +4,20 @@ declare(strict_types=1);
namespace App\Framework\Admin\Services;
use App\Framework\Admin\AdminApiHandler;
use App\Framework\Admin\AdminPageRenderer;
use App\Framework\Admin\Factories\AdminFormFactory;
use App\Framework\Admin\Factories\AdminTableFactory;
use App\Framework\Admin\Factories\RepositoryAdapterFactory;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Admin\ValueObjects\AdminTableConfig;
use App\Framework\Admin\ValueObjects\CrudConfig;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
use App\Framework\Http\Responses\ViewResult;
use App\Framework\View\TemplateRenderer;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
/**
* CRUD Service
@@ -21,32 +27,57 @@ use App\Framework\View\TemplateRenderer;
final readonly class CrudService
{
public function __construct(
private TemplateRenderer $renderer,
private AdminFormFactory $formFactory
private AdminPageRenderer $pageRenderer,
private AdminFormFactory $formFactory,
private AdminTableFactory $tableFactory,
private AdminApiHandler $apiHandler,
private RepositoryAdapterFactory $adapterFactory
) {
}
/**
* Render index view
* Render index view with table
*/
public function renderIndex(
CrudConfig $config,
array $items,
Request $request,
?array $pagination = null
?array $pagination = null,
?array $additionalActions = null
): ViewResult {
$data = [
...$config->toArray(),
'items' => $items,
'pagination' => $pagination,
'currentUrl' => $request->uri(),
'createUrl' => "/admin/{$config->resource}/create",
'editUrl' => "/admin/{$config->resource}/edit",
'viewUrl' => "/admin/{$config->resource}/view",
'deleteUrl' => "/admin/{$config->resource}/delete",
];
// Build table configuration from CrudConfig
$tableConfig = $this->buildTableConfig($config);
return new ViewResult('crud-index', $data);
// Create table using factory
$table = $this->tableFactory->create($tableConfig, $items);
// Build actions array
$actions = [];
// Add create action if allowed
if ($config->canCreate) {
$actions[] = [
'url' => "/admin/{$config->resource}/create",
'label' => "Create {$config->resourceName}",
'icon' => 'plus',
];
}
// Add additional actions from config or parameter
$allAdditionalActions = array_merge(
$config->additionalActions,
$additionalActions ?? []
);
$actions = array_merge($actions, $allAdditionalActions);
// Render using AdminPageRenderer
return $this->pageRenderer->renderIndex(
resource: $config->resource,
table: $table,
title: $config->title,
actions: $actions,
filterOptions: $config->filterOptions
);
}
/**
@@ -68,19 +99,15 @@ final readonly class CrudService
$form = $this->formFactory->create($formConfig);
$data = [
'title' => "Create {$config->resourceName}",
'subtitle' => "Add a new {$config->resourceName} to the system",
'formTitle' => "{$config->resourceName} Details",
'resourceName' => $config->resourceName,
'form' => $form->build(),
'formId' => $form->getId(),
'backUrl' => "/admin/{$config->resource}",
'saveAndContinue' => true,
'helpText' => $helpText,
];
return new ViewResult('crud-create', $data);
// Use AdminPageRenderer for consistent form rendering
$subtitle = $helpText ?? "Add a new {$config->resourceName} to the system";
return $this->pageRenderer->renderForm(
resource: $config->resource,
form: $form,
title: "Create {$config->resourceName}",
subtitle: $subtitle
);
}
/**
@@ -104,24 +131,15 @@ final readonly class CrudService
$form = $this->formFactory->create($formConfig);
$data = [
'title' => "Edit {$config->resourceName}",
'subtitle' => "Update {$config->resourceName} information",
'formTitle' => "{$config->resourceName} Details",
'resourceName' => $config->resourceName,
'form' => $form->build(),
'formId' => $form->getId(),
'backUrl' => "/admin/{$config->resource}",
'viewUrl' => "/admin/{$config->resource}/view/{$id}",
'deleteUrl' => "/admin/{$config->resource}/delete/{$id}",
'canView' => $config->canView,
'canDelete' => $config->canDelete,
'saveAndView' => true,
'metadata' => $metadata,
'helpText' => $helpText,
];
return new ViewResult('crud-edit', $data);
// Use AdminPageRenderer for consistent form rendering
$subtitle = $helpText ?? "Update {$config->resourceName} information";
return $this->pageRenderer->renderForm(
resource: $config->resource,
form: $form,
title: "Edit {$config->resourceName}",
subtitle: $subtitle
);
}
/**
@@ -135,15 +153,12 @@ final readonly class CrudService
?array $relatedItems = null,
?array $actions = null
): ViewResult {
// Build data array for show view
$data = [
'title' => "{$config->resourceName} Details",
'subtitle' => "View {$config->resourceName} information",
'detailsTitle' => "{$config->resourceName} Information",
'resourceName' => $config->resourceName,
'fields' => $fields,
'backUrl' => "/admin/{$config->resource}",
'editUrl' => "/admin/{$config->resource}/edit/{$id}",
'deleteUrl' => "/admin/{$config->resource}/delete/{$id}",
'editUrl' => $config->canEdit ? "/admin/{$config->resource}/edit/{$id}" : null,
'deleteUrl' => $config->canDelete ? "/admin/{$config->resource}/delete/{$id}" : null,
'canEdit' => $config->canEdit,
'canDelete' => $config->canDelete,
'metadata' => $metadata,
@@ -151,7 +166,12 @@ final readonly class CrudService
'actions' => $actions,
];
return new ViewResult('crud-show', $data);
// Use AdminPageRenderer for consistent show rendering
return $this->pageRenderer->renderShow(
resource: $config->resource,
title: "{$config->resourceName} Details",
data: $data
);
}
/**
@@ -212,4 +232,165 @@ final readonly class CrudService
return $redirect;
}
/**
* Build AdminTableConfig from CrudConfig
*/
private function buildTableConfig(CrudConfig $config): AdminTableConfig
{
// Convert CrudConfig columns to AdminTableConfig format
$tableColumns = $this->convertColumnsToTableFormat($config->columns);
// Determine row actions
$rowActions = $config->canEdit || $config->canView || $config->canDelete;
// Build edit/view URL templates
$editUrlTemplate = $config->canEdit
? "/admin/{$config->resource}/edit/{id}"
: null;
$viewUrlTemplate = $config->canView
? "/admin/{$config->resource}/view/{id}"
: null;
// Use custom table config if provided, otherwise build from CrudConfig
if ($config->tableConfig !== null) {
return AdminTableConfig::create(
resource: $config->resource,
columns: $config->tableConfig['columns'] ?? $tableColumns,
sortable: $config->tableConfig['sortable'] ?? true,
searchable: $config->tableConfig['searchable'] ?? $config->searchable,
bulkOperations: $config->tableConfig['bulkOperations'] ?? ($config->bulkActions !== null),
bulkActions: $config->tableConfig['bulkActions'] ?? $config->bulkActions ?? [],
rowActions: $config->tableConfig['rowActions'] ?? $rowActions,
editUrlTemplate: $config->tableConfig['editUrlTemplate'] ?? $editUrlTemplate,
viewUrlTemplate: $config->tableConfig['viewUrlTemplate'] ?? $viewUrlTemplate
);
}
return AdminTableConfig::create(
resource: $config->resource,
columns: $tableColumns,
sortable: true,
searchable: $config->searchable,
bulkOperations: $config->bulkActions !== null,
bulkActions: $config->bulkActions ?? [],
rowActions: $rowActions,
editUrlTemplate: $editUrlTemplate,
viewUrlTemplate: $viewUrlTemplate
);
}
/**
* Convert CrudConfig columns format to AdminTableConfig format
*
* Supports two formats:
* 1. ['field' => 'name', 'label' => 'Name', 'sortable' => true]
* 2. 'name' => ['label' => 'Name', 'sortable' => true]
*
* @param array<int|string, mixed> $columns
* @return array<string, array{label: string, sortable?: bool, searchable?: bool, formatter?: string, class?: string}>
*/
private function convertColumnsToTableFormat(array $columns): array
{
$tableColumns = [];
foreach ($columns as $key => $column) {
// Handle format 1: ['field' => 'name', 'label' => 'Name', ...]
if (isset($column['field'])) {
$fieldName = $column['field'];
$tableColumns[$fieldName] = [
'label' => $column['label'] ?? ucfirst($fieldName),
'sortable' => $column['sortable'] ?? false,
'searchable' => $column['searchable'] ?? false,
];
if (isset($column['formatter'])) {
$tableColumns[$fieldName]['formatter'] = $column['formatter'];
}
if (isset($column['class'])) {
$tableColumns[$fieldName]['class'] = $column['class'];
}
}
// Handle format 2: 'name' => ['label' => 'Name', ...]
elseif (is_string($key)) {
$tableColumns[$key] = [
'label' => $column['label'] ?? ucfirst($key),
'sortable' => $column['sortable'] ?? false,
'searchable' => $column['searchable'] ?? false,
];
if (isset($column['formatter'])) {
$tableColumns[$key]['formatter'] = $column['formatter'];
}
if (isset($column['class'])) {
$tableColumns[$key]['class'] = $column['class'];
}
}
}
return $tableColumns;
}
/**
* Create API route handler for list operations
*
* This method simplifies creating API endpoints for CRUD resources.
* Use it in your controller's apiList() method:
*
* #[Route('/admin/api/{resource}', Method::GET)]
* public function apiList(HttpRequest $request): JsonResult
* {
* return $this->crudService->handleApiList(
* config: $this->config,
* dataProvider: fn($filters) => $this->getAllData($filters),
* enricher: fn($item) => $this->enrichItem($item)
* );
* }
*
* @param CrudConfig $config The CRUD configuration
* @param \Closure(array): array $dataProvider Function that returns all data items
* @param \Closure(array): array|null $enricher Optional function to enrich each item
* @param HttpRequest $request The HTTP request
* @return JsonResult JSON response with data and pagination
*/
public function handleApiList(
CrudConfig $config,
\Closure $dataProvider,
HttpRequest $request,
?\Closure $enricher = null
): JsonResult {
$adapter = $this->adapterFactory->createFromProvider(
dataProvider: $dataProvider,
itemProvider: null, // Single item lookup not needed for list
enricher: $enricher
);
return $this->apiHandler->handleList($request, $adapter);
}
/**
* Create API route handler for single item operations
*
* @param CrudConfig $config The CRUD configuration
* @param \Closure(string): array|null $itemProvider Function that returns a single item by ID
* @param \Closure(array): array|null $enricher Optional function to enrich the item
* @param string $id The item ID
* @return JsonResult JSON response with item data
*/
public function handleApiGet(
CrudConfig $config,
\Closure $itemProvider,
string $id,
?\Closure $enricher = null
): JsonResult {
$adapter = $this->adapterFactory->createFromProvider(
dataProvider: fn() => [], // Not used for single item
itemProvider: $itemProvider,
enricher: $enricher
);
return $this->apiHandler->handleGet($id, $adapter);
}
}

View File

@@ -13,6 +13,10 @@ final readonly class AdminTableConfig
{
/**
* @param array<string, array{label: string, sortable?: bool, searchable?: bool, formatter?: string, class?: string}> $columns
* @param array<string, array{label: string, action: string, method?: string, confirm?: string}> $bulkActions
* @param bool $rowActions Whether to show row action buttons (edit, delete, etc.)
* @param string|null $editUrlTemplate URL template for edit action (e.g., '/admin/cms/contents/{id}/edit')
* @param string|null $viewUrlTemplate URL template for view action (e.g., '/admin/cms/contents/{id}')
*/
public function __construct(
public array $columns,
@@ -22,6 +26,11 @@ final readonly class AdminTableConfig
public bool $paginated = true,
public int $perPage = 25,
public ?string $apiEndpoint = null,
public bool $bulkOperations = false,
public array $bulkActions = [],
public bool $rowActions = false,
public ?string $editUrlTemplate = null,
public ?string $viewUrlTemplate = null,
) {
}
@@ -29,14 +38,24 @@ final readonly class AdminTableConfig
string $resource,
array $columns,
bool $sortable = true,
bool $searchable = true
bool $searchable = true,
bool $bulkOperations = false,
array $bulkActions = [],
bool $rowActions = false,
?string $editUrlTemplate = null,
?string $viewUrlTemplate = null,
): self {
return new self(
columns: $columns,
resource: $resource,
sortable: $sortable,
searchable: $searchable,
apiEndpoint: "/admin/api/{$resource}"
apiEndpoint: "/admin/api/{$resource}",
bulkOperations: $bulkOperations,
bulkActions: $bulkActions,
rowActions: $rowActions,
editUrlTemplate: $editUrlTemplate ?? "/admin/{$resource}/{id}/edit",
viewUrlTemplate: $viewUrlTemplate ?? "/admin/{$resource}/{id}",
);
}

View File

@@ -23,6 +23,9 @@ final readonly class CrudConfig
* @param array<string, mixed>|null $filters Filter definitions
* @param array<string, mixed>|null $bulkActions Bulk action definitions
* @param bool $searchable Enable search
* @param array<string, mixed> $filterOptions Filter options for template (e.g., dropdown options)
* @param array<string, mixed> $additionalActions Additional action buttons for index page
* @param array<string, mixed>|null $tableConfig Custom table configuration (optional)
*/
public function __construct(
public string $resource,
@@ -36,6 +39,9 @@ final readonly class CrudConfig
public ?array $filters = null,
public ?array $bulkActions = null,
public bool $searchable = true,
public array $filterOptions = [],
public array $additionalActions = [],
public ?array $tableConfig = null,
) {
}
@@ -64,7 +70,10 @@ final readonly class CrudConfig
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
searchable: $this->searchable,
filterOptions: $this->filterOptions,
additionalActions: $this->additionalActions,
tableConfig: $this->tableConfig
);
}
@@ -85,7 +94,10 @@ final readonly class CrudConfig
canDelete: $canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
searchable: $this->searchable,
filterOptions: $this->filterOptions,
additionalActions: $this->additionalActions,
tableConfig: $this->tableConfig
);
}
@@ -102,7 +114,10 @@ final readonly class CrudConfig
canDelete: $this->canDelete,
filters: $filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
searchable: $this->searchable,
filterOptions: $this->filterOptions,
additionalActions: $this->additionalActions,
tableConfig: $this->tableConfig
);
}
@@ -119,7 +134,70 @@ final readonly class CrudConfig
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $bulkActions,
searchable: $this->searchable
searchable: $this->searchable,
filterOptions: $this->filterOptions,
additionalActions: $this->additionalActions,
tableConfig: $this->tableConfig
);
}
public function withFilterOptions(array $filterOptions): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable,
filterOptions: $filterOptions,
additionalActions: $this->additionalActions,
tableConfig: $this->tableConfig
);
}
public function withAdditionalActions(array $additionalActions): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable,
filterOptions: $this->filterOptions,
additionalActions: $additionalActions,
tableConfig: $this->tableConfig
);
}
public function withTableConfig(array $tableConfig): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable,
filterOptions: $this->filterOptions,
additionalActions: $this->additionalActions,
tableConfig: $tableConfig
);
}
@@ -138,6 +216,9 @@ final readonly class CrudConfig
'filters' => $this->filters,
'bulkActions' => $this->bulkActions,
'searchable' => $this->searchable,
'filterOptions' => $this->filterOptions,
'additionalActions' => $this->additionalActions,
'tableConfig' => $this->tableConfig,
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Attributes\Execution\CallbackExecutor;
use App\Framework\Attributes\Execution\CallbackMetadata;
use App\Framework\Attributes\Execution\CallbackMetadataExtractor;
use App\Framework\Attributes\Execution\ExecutableAttribute;
use Attribute;
use Closure;
/**
* AfterExecute Attribute - Führt Logik NACH Handler-Ausführung aus
*
* Unterstützt alle drei Patterns:
* - Pattern A: Handler-Klasse: #[AfterExecute(NotificationHandler::class)]
* - Pattern B: First-Class Callable: #[AfterExecute(Notifiers::sendEmail(...))]
* - Pattern C: Closure-Factory: #[AfterExecute(Hooks::notifyUsers())]
*
* Wird nach erfolgreicher Handler-Ausführung ausgeführt.
* Hat Zugriff auf das Ergebnis der Handler-Ausführung über $context->targetValue.
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class AfterExecute implements ExecutableAttribute
{
private ?CallbackMetadata $callbackMetadata;
public function __construct(
private Closure $handler
) {
// Extrahiere Metadata für Cache-Kompatibilität
$extractor = new CallbackMetadataExtractor();
try {
$this->callbackMetadata = $extractor->extract($handler);
} catch (\InvalidArgumentException) {
// Fallback: Closure (nicht cachebar)
$this->callbackMetadata = null;
}
}
public function execute(AttributeExecutionContext $context): mixed
{
$callbackExecutor = $context->container->get(CallbackExecutor::class);
// Wenn Metadata vorhanden, nutze sie für Ausführung
if ($this->callbackMetadata !== null) {
$result = $callbackExecutor->execute($this->callbackMetadata, $context);
// Wenn Factory-Pattern, führe Closure aus
if ($result instanceof Closure) {
return $result($context);
}
return $result;
}
// Fallback: Direkte Closure-Ausführung (nicht empfohlen)
if ($this->handler instanceof Closure) {
return ($this->handler)($context);
}
throw new \RuntimeException(
'AfterExecute callback must be a Handler class, First-Class Callable, or Factory. Direct closures are not supported.'
);
}
/**
* Gibt die Callback-Metadata zurück (für Cache-Serialisierung)
*/
public function getCallbackMetadata(): ?CallbackMetadata
{
return $this->callbackMetadata;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Attributes\Execution\CallbackExecutor;
use App\Framework\Attributes\Execution\CallbackMetadata;
use App\Framework\Attributes\Execution\CallbackMetadataExtractor;
use App\Framework\Attributes\Execution\ExecutableAttribute;
use Attribute;
use Closure;
/**
* BeforeExecute Attribute - Führt Logik VOR Handler-Ausführung aus
*
* Unterstützt alle drei Patterns:
* - Pattern A: Handler-Klasse: #[BeforeExecute(ValidationHandler::class)]
* - Pattern B: First-Class Callable: #[BeforeExecute(Validators::validateInput(...))]
* - Pattern C: Closure-Factory: #[BeforeExecute(Hooks::validateCommand())]
*
* Wird vor der Handler-Methode ausgeführt. Kann die Ausführung abbrechen.
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class BeforeExecute implements ExecutableAttribute
{
private ?CallbackMetadata $callbackMetadata;
public function __construct(
private Closure $handler
) {
// Extrahiere Metadata für Cache-Kompatibilität
$extractor = new CallbackMetadataExtractor();
try {
$this->callbackMetadata = $extractor->extract($handler);
} catch (\InvalidArgumentException) {
// Fallback: Closure (nicht cachebar)
$this->callbackMetadata = null;
}
}
public function execute(AttributeExecutionContext $context): mixed
{
$callbackExecutor = $context->container->get(CallbackExecutor::class);
// Wenn Metadata vorhanden, nutze sie für Ausführung
if ($this->callbackMetadata !== null) {
$result = $callbackExecutor->execute($this->callbackMetadata, $context);
// Wenn Factory-Pattern, führe Closure aus
if ($result instanceof Closure) {
return $result($context);
}
return $result;
}
// Fallback: Direkte Closure-Ausführung (nicht empfohlen)
if ($this->handler instanceof Closure) {
return ($this->handler)($context);
}
throw new \RuntimeException(
'BeforeExecute callback must be a Handler class, First-Class Callable, or Factory. Direct closures are not supported.'
);
}
/**
* Gibt die Callback-Metadata zurück (für Cache-Serialisierung)
*/
public function getCallbackMetadata(): ?CallbackMetadata
{
return $this->callbackMetadata;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
use App\Framework\DI\Container;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
/**
* Kontext für die Ausführung von Attributen
*
* Stellt alle notwendigen Informationen für die Attribut-Ausführung bereit:
* - Container für Dependency Injection
* - Target-Informationen (Klasse, Methode, Property)
* - Zusätzliche Daten für spezielle Use Cases
*/
final readonly class AttributeExecutionContext
{
/**
* @param array<string, mixed> $additionalData
*/
public function __construct(
public Container $container,
public ?ClassName $targetClass = null,
public ?MethodName $targetMethod = null,
public ?string $targetProperty = null,
public mixed $targetValue = null,
public array $additionalData = []
) {
}
/**
* Erstellt Context für Klassen-Attribute
*/
public static function forClass(Container $container, ClassName $className): self
{
return new self(
container: $container,
targetClass: $className
);
}
/**
* Erstellt Context für Methoden-Attribute
*/
public static function forMethod(
Container $container,
ClassName $className,
MethodName $methodName
): self {
return new self(
container: $container,
targetClass: $className,
targetMethod: $methodName
);
}
/**
* Erstellt Context für Property-Attribute
*/
public static function forProperty(
Container $container,
ClassName $className,
string $propertyName,
mixed $value = null
): self {
return new self(
container: $container,
targetClass: $className,
targetProperty: $propertyName,
targetValue: $value
);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
/**
* DI Initializer für Attribute Execution Services
*
* Registriert alle notwendigen Services für das Attribute Execution System:
* - CallbackExecutor
* - AttributeRunner
* - CallbackMetadataExtractor
* - HandlerAttributeExecutor
*/
final readonly class AttributeExecutionInitializer
{
public function __construct(
private Container $container
) {
}
/**
* Registriert Attribute Execution Services als Singletons
*/
#[Initializer]
public function __invoke(): void
{
// CallbackExecutor als Singleton
$this->container->singleton(
CallbackExecutor::class,
fn (Container $container) => new CallbackExecutor($container)
);
// AttributeRunner als Singleton
$this->container->singleton(
AttributeRunner::class,
function (Container $container) {
$discoveryRegistry = $container->get(\App\Framework\Discovery\Results\DiscoveryRegistry::class);
$callbackExecutor = $container->get(CallbackExecutor::class);
return new AttributeRunner(
discoveryRegistry: $discoveryRegistry,
container: $container,
callbackExecutor: $callbackExecutor
);
}
);
// CallbackMetadataExtractor als Singleton (stateless)
$this->container->singleton(
CallbackMetadataExtractor::class,
fn () => new CallbackMetadataExtractor()
);
// HandlerAttributeExecutor als Singleton
$this->container->singleton(
HandlerAttributeExecutor::class,
function (Container $container) {
$discoveryRegistry = $container->get(\App\Framework\Discovery\Results\DiscoveryRegistry::class);
$attributeRunner = $container->get(AttributeRunner::class);
return new HandlerAttributeExecutor(
discoveryRegistry: $discoveryRegistry,
container: $container,
attributeRunner: $attributeRunner
);
}
);
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
/**
* Globaler Attribute Runner mit First-Class Callable Support
*
* Führt ausführbare Attribute zur Laufzeit aus.
* Unterstützt alle drei Patterns: Handler-Klassen, First-Class Callables, Closure-Factories.
*/
final readonly class AttributeRunner
{
public function __construct(
private DiscoveryRegistry $discoveryRegistry,
private Container $container,
private CallbackExecutor $callbackExecutor
) {
}
/**
* Führt alle Attribute eines bestimmten Typs aus
*
* @param class-string<ExecutableAttribute> $attributeClass Attribut-Klasse
* @param AttributeExecutionContext|null $context Optionaler Kontext (wird automatisch erstellt falls null)
* @return array<mixed> Ergebnisse der Attribut-Ausführung
*/
public function executeAttributes(
string $attributeClass,
?AttributeExecutionContext $context = null
): array {
$attributes = $this->discoveryRegistry->attributes->get($attributeClass);
$results = [];
foreach ($attributes as $discovered) {
$result = $this->executeAttribute($discovered, $context);
if ($result !== null) {
$results[] = $result;
}
}
return $results;
}
/**
* Führt ein einzelnes Attribut aus
*
* @param DiscoveredAttribute $discovered Entdecktes Attribut
* @param AttributeExecutionContext|null $context Optionaler Kontext
* @return mixed Ergebnis der Attribut-Ausführung oder null wenn nicht ausführbar
*/
public function executeAttribute(
DiscoveredAttribute $discovered,
?AttributeExecutionContext $context = null
): mixed {
$instance = $discovered->createAttributeInstance();
if (!$instance instanceof ExecutableAttribute) {
return null;
}
// Context erstellen falls nicht vorhanden
if ($context === null) {
$context = $this->createContext($discovered);
}
return $instance->execute($context);
}
/**
* Führt Attribute für eine bestimmte Klasse aus
*
* @param ClassName $className Ziel-Klasse
* @param string $attributeClass Attribut-Klasse
* @return array<mixed> Ergebnisse der Attribut-Ausführung
*/
public function executeForClass(ClassName $className, string $attributeClass): array
{
$attributes = $this->discoveryRegistry->attributes->get($attributeClass);
$results = [];
foreach ($attributes as $discovered) {
if (!$discovered->className->equals($className)) {
continue;
}
$context = AttributeExecutionContext::forClass(
$this->container,
$className
);
$result = $this->executeAttribute($discovered, $context);
if ($result !== null) {
$results[] = $result;
}
}
return $results;
}
/**
* Führt Attribute für eine bestimmte Methode aus
*
* @param ClassName $className Ziel-Klasse
* @param MethodName $methodName Ziel-Methode
* @param string $attributeClass Attribut-Klasse
* @return array<mixed> Ergebnisse der Attribut-Ausführung
*/
public function executeForMethod(
ClassName $className,
MethodName $methodName,
string $attributeClass
): array {
$attributes = $this->discoveryRegistry->attributes->get($attributeClass);
$results = [];
foreach ($attributes as $discovered) {
if (!$discovered->className->equals($className)) {
continue;
}
if ($discovered->methodName === null ||
!$discovered->methodName->equals($methodName)) {
continue;
}
$context = AttributeExecutionContext::forMethod(
$this->container,
$className,
$methodName
);
$result = $this->executeAttribute($discovered, $context);
if ($result !== null) {
$results[] = $result;
}
}
return $results;
}
/**
* Erstellt einen Context basierend auf DiscoveredAttribute
*/
private function createContext(DiscoveredAttribute $discovered): AttributeExecutionContext
{
if ($discovered->isMethodAttribute() && $discovered->methodName !== null) {
return AttributeExecutionContext::forMethod(
$this->container,
$discovered->className,
$discovered->methodName
);
}
if ($discovered->isPropertyAttribute() && $discovered->propertyName !== null) {
return AttributeExecutionContext::forProperty(
$this->container,
$discovered->className,
$discovered->propertyName
);
}
return AttributeExecutionContext::forClass(
$this->container,
$discovered->className
);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
use App\Framework\DI\Container;
use App\Framework\DI\MethodInvoker;
use Closure;
/**
* Callback Executor - Führt Callbacks basierend auf Metadata aus
*
* Unterstützt alle drei Patterns:
* - Pattern A: Handler-Klassen (nutzt Container::make())
* - Pattern B: First-Class Callables (nutzt MethodInvoker::invokeStatic())
* - Pattern C: Closure-Factories (führt Factory aus und gibt Closure zurück)
*/
final readonly class CallbackExecutor
{
public function __construct(
private Container $container
) {
}
/**
* Führt einen Callback basierend auf Metadata aus
*
* @param CallbackMetadata $metadata Callback-Metadata
* @param AttributeExecutionContext $context Ausführungskontext
* @return mixed Ergebnis der Callback-Ausführung
* @throws \RuntimeException Wenn Callback-Typ nicht unterstützt wird oder Ausführung fehlschlägt
*/
public function execute(
CallbackMetadata $metadata,
AttributeExecutionContext $context
): mixed {
return match ($metadata->callbackType) {
CallbackType::HANDLER => $this->executeHandler($metadata, $context),
CallbackType::STATIC_METHOD => $this->executeStaticMethod($metadata, $context),
CallbackType::FACTORY => $this->executeFactory($metadata, $context),
CallbackType::CLOSURE => throw new \RuntimeException(
'Closure execution not supported via metadata. Use factory pattern instead.'
),
};
}
/**
* Pattern A: Handler-Klasse ausführen
*
* Erstellt Handler-Instanz mit Container und ruft check(), handle() oder __invoke() auf.
*/
private function executeHandler(
CallbackMetadata $metadata,
AttributeExecutionContext $context
): mixed {
// Container erstellt Handler mit Argumenten über MethodInvoker
$methodInvoker = $this->container->get(MethodInvoker::class);
$handler = $methodInvoker->make($metadata->class, $metadata->args);
// Handler muss eine check(), handle() oder __invoke() Methode haben
if (method_exists($handler, 'check')) {
return $handler->check($context);
}
if (method_exists($handler, 'handle')) {
return $handler->handle($context);
}
if (method_exists($handler, '__invoke')) {
return $handler($context);
}
throw new \RuntimeException(
"Handler {$metadata->class} must implement check(), handle() or __invoke()"
);
}
/**
* Pattern B: Statische Methode ausführen
*
* Ruft statische Methode mit Dependency Injection über MethodInvoker auf.
*/
private function executeStaticMethod(
CallbackMetadata $metadata,
AttributeExecutionContext $context
): mixed {
if ($metadata->method === null) {
throw new \RuntimeException(
"Static method name is required for STATIC_METHOD callback type"
);
}
// Mit DI über MethodInvoker
$methodInvoker = $this->container->get(MethodInvoker::class);
// Context als Parameter übergeben
return $methodInvoker->invokeStatic(
$metadata->class,
$metadata->method,
['context' => $context] // Context als Parameter
);
}
/**
* Pattern C: Factory-Methode ausführen und Closure erstellen
*
* Führt Factory-Methode aus und gibt die erstellte Closure zurück.
* Die Closure wird zur Laufzeit erstellt, nicht gecacht.
*/
private function executeFactory(
CallbackMetadata $metadata,
AttributeExecutionContext $context
): Closure {
if ($metadata->method === null) {
throw new \RuntimeException(
"Factory method name is required for FACTORY callback type"
);
}
// Factory-Methode aufrufen
$methodInvoker = $this->container->get(MethodInvoker::class);
// Factory-Argumente übergeben (z.B. 'edit_post' für requirePermission)
$closure = $methodInvoker->invokeStatic(
$metadata->class,
$metadata->method,
$metadata->args // Factory-Argumente
);
if (!$closure instanceof Closure) {
throw new \RuntimeException(
"Factory {$metadata->class}::{$metadata->method} must return a Closure"
);
}
return $closure;
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
/**
* Callback Metadata für Cache-Serialisierung
*
* Speichert alle notwendigen Informationen für die Ausführung eines Callbacks,
* ohne die Closure selbst zu speichern (für Cache-Kompatibilität).
*
* Unterstützt drei Patterns:
* - Handler-Klassen (Pattern A)
* - First-Class Callables (Pattern B)
* - Closure-Factories (Pattern C)
*/
final readonly class CallbackMetadata
{
/**
* @param array<string, mixed> $args Argumente für Handler/Factory
*/
public function __construct(
public CallbackType $callbackType,
public string $class,
public ?string $method = null,
public array $args = []
) {
}
/**
* Erstellt Metadata aus Handler-Klasse (Pattern A)
*
* Beispiel: PermissionGuard::class, ['edit_post']
*/
public static function fromHandler(string $handlerClass, array $args = []): self
{
return new self(
callbackType: CallbackType::HANDLER,
class: $handlerClass,
args: $args
);
}
/**
* Erstellt Metadata aus First-Class Callable (Pattern B)
*
* Unterstützt:
* - String: "MyClass::method"
* - Array: [MyClass::class, 'method']
*/
public static function fromCallable(callable|array $callable): self
{
if (is_string($callable) && str_contains($callable, '::')) {
[$class, $method] = explode('::', $callable, 2);
return new self(
callbackType: CallbackType::STATIC_METHOD,
class: $class,
method: $method
);
}
if (is_array($callable) && is_string($callable[0])) {
return new self(
callbackType: CallbackType::STATIC_METHOD,
class: $callable[0],
method: $callable[1]
);
}
throw new \InvalidArgumentException(
'Unsupported callable type for metadata. Expected string with "::" or array with string class.'
);
}
/**
* Erstellt Metadata aus Factory-Methode (Pattern C)
*
* Beispiel: Policies::requirePermission('edit_post')
*
* @param string $factoryClass Klassenname der Factory
* @param string $factoryMethod Methodenname der Factory
* @param array<string, mixed> $args Argumente für die Factory-Methode
*/
public static function fromFactory(
string $factoryClass,
string $factoryMethod,
array $args = []
): self {
return new self(
callbackType: CallbackType::FACTORY,
class: $factoryClass,
method: $factoryMethod,
args: $args
);
}
/**
* Konvertiert zu Array für Cache-Serialisierung
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'callbackType' => $this->callbackType->value,
'class' => $this->class,
'method' => $this->method,
'args' => $this->args,
];
}
/**
* Erstellt aus Array (Cache-Deserialisierung)
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
callbackType: CallbackType::from($data['callbackType']),
class: $data['class'],
method: $data['method'] ?? null,
args: $data['args'] ?? []
);
}
/**
* Prüft ob dieser Callback cachebar ist
*
* Closures sind nicht serialisierbar und daher nicht cachebar.
* Alle anderen Patterns sind cachebar.
*/
public function isCacheable(): bool
{
return $this->callbackType !== CallbackType::CLOSURE;
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
use Closure;
/**
* Callback Metadata Extractor - Extrahiert Metadata aus Attribut-Argumenten
*
* Erkennt automatisch verschiedene Callback-Patterns:
* - Pattern A: Handler-Klassen (String-Klassennamen)
* - Pattern B: First-Class Callables (String mit "::" oder Array)
* - Pattern C: Closure-Factories (muss explizit extrahiert werden)
*/
final readonly class CallbackMetadataExtractor
{
/**
* Extrahiert Metadata aus einem Callable/Handler-Argument
*
* Unterstützt:
* - Handler-Klasse als String: "MyHandler"
* - Handler-Klasse mit Argumenten: ["MyHandler", ['arg1', 'arg2']]
* - First-Class Callable (String): "MyClass::method"
* - First-Class Callable (Array): [MyClass::class, 'method']
*
* @param mixed $callback Callback-Argument aus Attribut
* @return CallbackMetadata Extrahierte Metadata
* @throws \InvalidArgumentException Wenn Callback-Typ nicht unterstützt wird
*/
public function extract(mixed $callback): CallbackMetadata
{
// Pattern A: Handler-Klasse als String
if (is_string($callback) && class_exists($callback)) {
return CallbackMetadata::fromHandler($callback);
}
// Pattern A: Handler-Klasse mit Argumenten als Array
// Format: [HandlerClass::class, ['arg1', 'arg2']]
if (is_array($callback) &&
isset($callback[0]) &&
is_string($callback[0]) &&
class_exists($callback[0])) {
return CallbackMetadata::fromHandler(
$callback[0],
$callback[1] ?? []
);
}
// Pattern B: First-Class Callable (String)
// Format: "MyClass::method"
if (is_string($callback) && str_contains($callback, '::')) {
return CallbackMetadata::fromCallable($callback);
}
// Pattern B: First-Class Callable (Array)
// Format: [MyClass::class, 'method']
if (is_array($callback) &&
is_string($callback[0]) &&
isset($callback[1]) &&
is_string($callback[1])) {
return CallbackMetadata::fromCallable($callback);
}
// Closure (nicht cachebar, sollte vermieden werden)
if ($callback instanceof Closure) {
return new CallbackMetadata(
callbackType: CallbackType::CLOSURE,
class: '',
method: null
);
}
throw new \InvalidArgumentException(
'Unsupported callback type. Expected handler class, callable string with "::", or callable array.'
);
}
/**
* Extrahiert Metadata aus einem Factory-Aufruf
*
* Für Factory-Pattern (Pattern C) muss die Factory-Methode explizit
* identifiziert werden. Dies geschieht typischerweise zur Discovery-Zeit
* durch Analyse der Attribut-Argumente.
*
* Beispiel: Policies::requirePermission('edit_post')
*
* @param string $factoryClass Klassenname der Factory
* @param string $factoryMethod Methodenname der Factory
* @param array<string, mixed> $factoryArgs Argumente für die Factory-Methode
* @return CallbackMetadata Metadata für Factory-Pattern
*/
public function extractFromFactoryCall(
string $factoryClass,
string $factoryMethod,
array $factoryArgs = []
): CallbackMetadata {
return CallbackMetadata::fromFactory(
$factoryClass,
$factoryMethod,
$factoryArgs
);
}
/**
* Prüft ob ein Argument ein Callback-Argument ist
*
* @param mixed $value Zu prüfender Wert
* @return bool True wenn es sich um ein Callback-Argument handelt
*/
public function isCallbackArgument(mixed $value): bool
{
// Handler-Klasse (String)
if (is_string($value) && class_exists($value)) {
return true;
}
// Handler-Klasse mit Argumenten oder Callable-Array
if (is_array($value) && isset($value[0])) {
// Handler-Klasse
if (is_string($value[0]) && class_exists($value[0])) {
return true;
}
// First-Class Callable
if (is_string($value[0]) && isset($value[1]) && is_string($value[1])) {
return true;
}
}
// First-Class Callable (String mit ::)
if (is_string($value) && str_contains($value, '::')) {
return true;
}
// Closure
if ($value instanceof Closure) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
/**
* Enum für verschiedene Callback-Ausführungsarten
*
* Definiert die verschiedenen Patterns für Attribut-Ausführung:
* - HANDLER: Handler-Klasse (Pattern A)
* - STATIC_METHOD: First-Class Callable (Pattern B)
* - FACTORY: Closure-Factory (Pattern C)
* - CLOSURE: Direkte Closure (nicht cachebar)
*/
enum CallbackType: string
{
case HANDLER = 'handler';
case STATIC_METHOD = 'static_method';
case FACTORY = 'factory';
case CLOSURE = 'closure';
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
/**
* Interface für ausführbare Attribute
*
* Attribute die dieses Interface implementieren können direkt
* durch den AttributeRunner ausgeführt werden.
*
* Die Ausführung erfolgt zur Laufzeit mit Dependency Injection
* über den AttributeExecutionContext.
*/
interface ExecutableAttribute
{
/**
* Führt die Attribut-Logik aus
*
* @param AttributeExecutionContext $context Ausführungskontext mit Container und Target-Info
* @return mixed Ergebnis der Ausführung (optional)
*/
public function execute(AttributeExecutionContext $context): mixed;
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use ReflectionMethod;
/**
* Handler Attribute Executor - Führt Attribute auf Handler-Methoden aus
*
* Speziell für Command/Query-Handler optimiert.
* Unterstützt Before/After/OnError Attribute.
*/
final readonly class HandlerAttributeExecutor
{
public function __construct(
private DiscoveryRegistry $discoveryRegistry,
private Container $container,
private AttributeRunner $attributeRunner
) {
}
/**
* Führt "Before"-Attribute für einen Handler aus
*
* @param string $handlerClass Handler-Klasse
* @param string $handlerMethod Handler-Methode
* @param object $command Command/Query-Objekt
*/
public function executeBefore(
string $handlerClass,
string $handlerMethod,
object $command
): void {
$this->executeHandlerAttributes(
$handlerClass,
$handlerMethod,
BeforeExecute::class,
$command
);
}
/**
* Führt "After"-Attribute für einen Handler aus
*
* @param string $handlerClass Handler-Klasse
* @param string $handlerMethod Handler-Methode
* @param object $command Command/Query-Objekt
* @param mixed $result Ergebnis der Handler-Ausführung
*/
public function executeAfter(
string $handlerClass,
string $handlerMethod,
object $command,
mixed $result
): void {
$context = AttributeExecutionContext::forMethod(
$this->container,
ClassName::create($handlerClass),
MethodName::create($handlerMethod)
);
// Füge Result zum Context hinzu
$context = new AttributeExecutionContext(
container: $context->container,
targetClass: $context->targetClass,
targetMethod: $context->targetMethod,
targetProperty: $context->targetProperty,
targetValue: $result,
additionalData: array_merge($context->additionalData, [
'command' => $command,
'result' => $result,
])
);
$this->executeHandlerAttributesForContext(
$handlerClass,
$handlerMethod,
AfterExecute::class,
$context
);
}
/**
* Führt "OnError"-Attribute für einen Handler aus
*
* @param string $handlerClass Handler-Klasse
* @param string $handlerMethod Handler-Methode
* @param object $command Command/Query-Objekt
* @param \Throwable $exception Aufgetretene Exception
*/
public function executeOnError(
string $handlerClass,
string $handlerMethod,
object $command,
\Throwable $exception
): void {
$context = AttributeExecutionContext::forMethod(
$this->container,
ClassName::create($handlerClass),
MethodName::create($handlerMethod)
);
// Füge Exception zum Context hinzu
$context = new AttributeExecutionContext(
container: $context->container,
targetClass: $context->targetClass,
targetMethod: $context->targetMethod,
targetProperty: $context->targetProperty,
targetValue: null,
additionalData: array_merge($context->additionalData, [
'command' => $command,
'exception' => $exception,
])
);
$this->executeHandlerAttributesForContext(
$handlerClass,
$handlerMethod,
OnError::class,
$context
);
}
/**
* Führt Attribute eines bestimmten Typs für einen Handler aus
*/
private function executeHandlerAttributes(
string $handlerClass,
string $handlerMethod,
string $attributeClass,
object $command
): void {
$context = AttributeExecutionContext::forMethod(
$this->container,
ClassName::create($handlerClass),
MethodName::create($handlerMethod)
);
// Füge Command zum Context hinzu
$context = new AttributeExecutionContext(
container: $context->container,
targetClass: $context->targetClass,
targetMethod: $context->targetMethod,
targetProperty: $context->targetProperty,
targetValue: $command,
additionalData: array_merge($context->additionalData, [
'command' => $command,
])
);
$this->executeHandlerAttributesForContext(
$handlerClass,
$handlerMethod,
$attributeClass,
$context
);
}
/**
* Führt Attribute für einen Handler mit gegebenem Context aus
*/
private function executeHandlerAttributesForContext(
string $handlerClass,
string $handlerMethod,
string $attributeClass,
AttributeExecutionContext $context
): void {
$attributes = $this->discoveryRegistry->attributes->get($attributeClass);
foreach ($attributes as $discovered) {
// Prüfe ob Attribut zu diesem Handler gehört
if (!$discovered->className->equals(ClassName::create($handlerClass))) {
continue;
}
if ($discovered->methodName === null ||
!$discovered->methodName->equals(MethodName::create($handlerMethod))) {
continue;
}
// Führe Attribut aus
$this->attributeRunner->executeAttribute($discovered, $context);
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution\Handlers;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
/**
* Permission Guard Handler (Pattern A)
*
* Beispiel-Handler für Permission-Checks.
*
* Verwendung:
* #[Guard(PermissionGuard::class, ['edit_post', 'delete_post'])]
*/
final readonly class PermissionGuard
{
/**
* @param array<string> $permissions Erforderliche Permissions
*/
public function __construct(
private readonly array $permissions
) {
}
/**
* Prüft ob der aktuelle User die erforderlichen Permissions hat
*
* @param AttributeExecutionContext $context Ausführungskontext
* @return bool True wenn User alle Permissions hat
*/
public function check(AttributeExecutionContext $context): bool
{
// Beispiel-Implementierung: Hole User aus Container
// In einer echten Implementierung würde hier der User-Service verwendet
try {
// Annahme: Es gibt einen User-Service im Container
// $user = $context->container->get(UserService::class)->getCurrentUser();
// return $user->hasPermissions(...$this->permissions);
// Placeholder für echte Implementierung
return true;
} catch (\Throwable) {
return false;
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution\Policies;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use Closure;
/**
* Policies Factory (Pattern C)
*
* Factory-Methoden für parametrisierte Policies mit Closure-Factories.
*
* Verwendung:
* #[Guard(Policies::requirePermission('edit_post'))]
* #[Guard(Policies::requireAnyPermission('edit_post', 'delete_post'))]
*/
final readonly class Policies
{
/**
* Erstellt eine Closure die prüft ob User eine bestimmte Permission hat
*
* @param string $permission Erforderliche Permission
* @return Closure Closure die AttributeExecutionContext akzeptiert und bool zurückgibt
*/
public static function requirePermission(string $permission): Closure
{
return static function (AttributeExecutionContext $context) use ($permission): bool {
// Beispiel-Implementierung: Hole User aus Container
try {
// Annahme: Es gibt einen User-Service im Container
// $user = $context->container->get(UserService::class)->getCurrentUser();
// return $user->hasPermission($permission);
// Placeholder für echte Implementierung
return false;
} catch (\Throwable) {
return false;
}
};
}
/**
* Erstellt eine Closure die prüft ob User mindestens eine der Permissions hat
*
* @param string ...$permissions Erforderliche Permissions (mindestens eine)
* @return Closure Closure die AttributeExecutionContext akzeptiert und bool zurückgibt
*/
public static function requireAnyPermission(string ...$permissions): Closure
{
return static function (AttributeExecutionContext $context) use ($permissions): bool {
// Beispiel-Implementierung: Hole User aus Container
try {
// Annahme: Es gibt einen User-Service im Container
// $user = $context->container->get(UserService::class)->getCurrentUser();
// foreach ($permissions as $permission) {
// if ($user->hasPermission($permission)) {
// return true;
// }
// }
// return false;
// Placeholder für echte Implementierung
return false;
} catch (\Throwable) {
return false;
}
};
}
/**
* Erstellt eine Closure die prüft ob User alle Permissions hat
*
* @param string ...$permissions Erforderliche Permissions (alle)
* @return Closure Closure die AttributeExecutionContext akzeptiert und bool zurückgibt
*/
public static function requireAllPermissions(string ...$permissions): Closure
{
return static function (AttributeExecutionContext $context) use ($permissions): bool {
// Beispiel-Implementierung: Hole User aus Container
try {
// Annahme: Es gibt einen User-Service im Container
// $user = $context->container->get(UserService::class)->getCurrentUser();
// foreach ($permissions as $permission) {
// if (!$user->hasPermission($permission)) {
// return false;
// }
// }
// return true;
// Placeholder für echte Implementierung
return false;
} catch (\Throwable) {
return false;
}
};
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes\Execution\Policies;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
/**
* User Policies (Pattern B)
*
* Statische Methoden für Policy-Checks mit First-Class Callables.
*
* Verwendung:
* #[Guard(UserPolicies::isAdmin(...))]
* #[Guard(UserPolicies::hasRole('admin', ...))]
*/
final readonly class UserPolicies
{
/**
* Prüft ob der aktuelle User ein Admin ist
*
* @param AttributeExecutionContext $context Ausführungskontext
* @return bool True wenn User Admin ist
*/
public static function isAdmin(AttributeExecutionContext $context): bool
{
// Beispiel-Implementierung: Hole User aus Container
try {
// Annahme: Es gibt einen User-Service im Container
// $user = $context->container->get(UserService::class)->getCurrentUser();
// return $user->isAdmin();
// Placeholder für echte Implementierung
return false;
} catch (\Throwable) {
return false;
}
}
/**
* Prüft ob der aktuelle User eine bestimmte Rolle hat
*
* @param string $role Erforderliche Rolle
* @param AttributeExecutionContext $context Ausführungskontext
* @return bool True wenn User die Rolle hat
*/
public static function hasRole(string $role, AttributeExecutionContext $context): bool
{
// Beispiel-Implementierung: Hole User aus Container
try {
// Annahme: Es gibt einen User-Service im Container
// $user = $context->container->get(UserService::class)->getCurrentUser();
// return $user->hasRole($role);
// Placeholder für echte Implementierung
return false;
} catch (\Throwable) {
return false;
}
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Attributes\Execution\CallbackExecutor;
use App\Framework\Attributes\Execution\CallbackMetadata;
use App\Framework\Attributes\Execution\CallbackMetadataExtractor;
use App\Framework\Attributes\Execution\ExecutableAttribute;
use Attribute;
use Closure;
/**
* Guard Attribute - Schützt Methoden/Klassen mit Guards
*
* Unterstützt alle drei Patterns:
* - Pattern A: Handler-Klasse: #[Guard(PermissionGuard::class, ['edit_post'])]
* - Pattern B: First-Class Callable: #[Guard(UserPolicies::isAdmin(...))]
* - Pattern C: Closure-Factory: #[Guard(Policies::requirePermission('edit_post'))]
*
* Der Guard wird zur Laufzeit ausgeführt und sollte true zurückgeben wenn Zugriff erlaubt ist.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final readonly class Guard implements ExecutableAttribute
{
private ?CallbackMetadata $callbackMetadata;
public function __construct(
private Closure $guard
) {
// Extrahiere Metadata für Cache-Kompatibilität
$extractor = new CallbackMetadataExtractor();
try {
$this->callbackMetadata = $extractor->extract($guard);
} catch (\InvalidArgumentException) {
// Fallback: Closure (nicht cachebar)
$this->callbackMetadata = null;
}
}
public function execute(AttributeExecutionContext $context): mixed
{
$callbackExecutor = $context->container->get(CallbackExecutor::class);
// Wenn Metadata vorhanden, nutze sie für Ausführung
if ($this->callbackMetadata !== null) {
$result = $callbackExecutor->execute($this->callbackMetadata, $context);
// Wenn Factory-Pattern, führe Closure aus
if ($result instanceof Closure) {
return $result($context);
}
return $result;
}
// Fallback: Direkte Closure-Ausführung (nicht empfohlen)
if ($this->guard instanceof Closure) {
return ($this->guard)($context);
}
throw new \RuntimeException(
'Guard callback must be a Handler class, First-Class Callable, or Factory. Direct closures are not supported.'
);
}
/**
* Gibt die Callback-Metadata zurück (für Cache-Serialisierung)
*/
public function getCallbackMetadata(): ?CallbackMetadata
{
return $this->callbackMetadata;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Attributes\Execution\CallbackExecutor;
use App\Framework\Attributes\Execution\CallbackMetadata;
use App\Framework\Attributes\Execution\CallbackMetadataExtractor;
use App\Framework\Attributes\Execution\ExecutableAttribute;
use Attribute;
use Closure;
/**
* OnBoot Attribute - Führt Boot-Logik beim Laden einer Klasse aus
*
* Unterstützt alle drei Patterns:
* - Pattern A: Handler-Klasse: #[OnBoot(ServiceRegistrar::class)]
* - Pattern B: First-Class Callable: #[OnBoot(ServiceRegistrar::registerServices(...))]
* - Pattern C: Closure-Factory: #[OnBoot(BootHooks::registerService('my-service'))]
*
* Der Boot-Handler wird zur Laufzeit ausgeführt wenn die Klasse geladen wird.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class OnBoot implements ExecutableAttribute
{
private ?CallbackMetadata $callbackMetadata;
public function __construct(
private Closure $bootHandler
) {
// Extrahiere Metadata für Cache-Kompatibilität
$extractor = new CallbackMetadataExtractor();
try {
$this->callbackMetadata = $extractor->extract($bootHandler);
} catch (\InvalidArgumentException) {
// Fallback: Closure (nicht cachebar)
$this->callbackMetadata = null;
}
}
public function execute(AttributeExecutionContext $context): mixed
{
$callbackExecutor = $context->container->get(CallbackExecutor::class);
// Wenn Metadata vorhanden, nutze sie für Ausführung
if ($this->callbackMetadata !== null) {
$result = $callbackExecutor->execute($this->callbackMetadata, $context);
// Wenn Factory-Pattern, führe Closure aus
if ($result instanceof Closure) {
return $result($context);
}
return $result;
}
// Fallback: Direkte Closure-Ausführung (nicht empfohlen)
if ($this->bootHandler instanceof Closure) {
return ($this->bootHandler)($context);
}
throw new \RuntimeException(
'OnBoot callback must be a Handler class, First-Class Callable, or Factory. Direct closures are not supported.'
);
}
/**
* Gibt die Callback-Metadata zurück (für Cache-Serialisierung)
*/
public function getCallbackMetadata(): ?CallbackMetadata
{
return $this->callbackMetadata;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Attributes\Execution\CallbackExecutor;
use App\Framework\Attributes\Execution\CallbackMetadata;
use App\Framework\Attributes\Execution\CallbackMetadataExtractor;
use App\Framework\Attributes\Execution\ExecutableAttribute;
use Attribute;
use Closure;
/**
* OnError Attribute - Führt Logik bei Fehlern aus
*
* Unterstützt alle drei Patterns:
* - Pattern A: Handler-Klasse: #[OnError(ErrorHandler::class)]
* - Pattern B: First-Class Callable: #[OnError(ErrorHandlers::logAndNotify(...))]
* - Pattern C: Closure-Factory: #[OnError(Hooks::handleError())]
*
* Wird bei Exceptions während der Handler-Ausführung ausgeführt.
* Hat Zugriff auf die Exception über $context->additionalData['exception'].
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class OnError implements ExecutableAttribute
{
private ?CallbackMetadata $callbackMetadata;
public function __construct(
private Closure $handler
) {
// Extrahiere Metadata für Cache-Kompatibilität
$extractor = new CallbackMetadataExtractor();
try {
$this->callbackMetadata = $extractor->extract($handler);
} catch (\InvalidArgumentException) {
// Fallback: Closure (nicht cachebar)
$this->callbackMetadata = null;
}
}
public function execute(AttributeExecutionContext $context): mixed
{
$callbackExecutor = $context->container->get(CallbackExecutor::class);
// Wenn Metadata vorhanden, nutze sie für Ausführung
if ($this->callbackMetadata !== null) {
$result = $callbackExecutor->execute($this->callbackMetadata, $context);
// Wenn Factory-Pattern, führe Closure aus
if ($result instanceof Closure) {
return $result($context);
}
return $result;
}
// Fallback: Direkte Closure-Ausführung (nicht empfohlen)
if ($this->handler instanceof Closure) {
return ($this->handler)($context);
}
throw new \RuntimeException(
'OnError callback must be a Handler class, First-Class Callable, or Factory. Direct closures are not supported.'
);
}
/**
* Gibt die Callback-Metadata zurück (für Cache-Serialisierung)
*/
public function getCallbackMetadata(): ?CallbackMetadata
{
return $this->callbackMetadata;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Framework\Attributes;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Attributes\Execution\CallbackExecutor;
use App\Framework\Attributes\Execution\CallbackMetadata;
use App\Framework\Attributes\Execution\CallbackMetadataExtractor;
use App\Framework\Attributes\Execution\ExecutableAttribute;
use Attribute;
use Closure;
/**
* Validate Attribute - Validiert Property- oder Parameter-Werte
*
* Unterstützt alle drei Patterns:
* - Pattern A: Handler-Klasse: #[Validate(EmailValidator::class)]
* - Pattern B: First-Class Callable: #[Validate(Validators::isEmail(...))]
* - Pattern C: Closure-Factory: #[Validate(Validators::minLength(5))]
*
* Der Validator wird zur Laufzeit ausgeführt und sollte true zurückgeben wenn Wert gültig ist.
*/
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final readonly class Validate implements ExecutableAttribute
{
private ?CallbackMetadata $callbackMetadata;
public function __construct(
private Closure $validator
) {
// Extrahiere Metadata für Cache-Kompatibilität
$extractor = new CallbackMetadataExtractor();
try {
$this->callbackMetadata = $extractor->extract($validator);
} catch (\InvalidArgumentException) {
// Fallback: Closure (nicht cachebar)
$this->callbackMetadata = null;
}
}
public function execute(AttributeExecutionContext $context): mixed
{
$value = $context->targetValue;
if ($value === null) {
throw new \InvalidArgumentException(
'Validate attribute requires a target value in context'
);
}
$callbackExecutor = $context->container->get(CallbackExecutor::class);
// Wenn Metadata vorhanden, nutze sie für Ausführung
if ($this->callbackMetadata !== null) {
$result = $callbackExecutor->execute($this->callbackMetadata, $context);
// Wenn Factory-Pattern, führe Closure mit Wert aus
if ($result instanceof Closure) {
return $result($value);
}
// Wenn Handler/Callable, sollte es den Wert als Parameter erhalten
// Für Handler/Callable wird der Wert über Context bereitgestellt
return $result;
}
// Fallback: Direkte Closure-Ausführung (nicht empfohlen)
if ($this->validator instanceof Closure) {
return ($this->validator)($value);
}
throw new \RuntimeException(
'Validate callback must be a Handler class, First-Class Callable, or Factory. Direct closures are not supported.'
);
}
/**
* Gibt die Callback-Metadata zurück (für Cache-Serialisierung)
*/
public function getCallbackMetadata(): ?CallbackMetadata
{
return $this->callbackMetadata;
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Framework\Cache;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Filesystem\FileStorage;
use App\Framework\Filesystem\Storage;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Serializer\Serializer;
@@ -62,10 +64,14 @@ final class CacheBuilder
/**
* Fügt Performance-Metriken hinzu
*/
public function withMetrics(PerformanceCollectorInterface $collector, bool $enabled = true): self
public function withMetrics(PerformanceCollectorInterface $collector, ?Storage $storage = null, bool $enabled = true): self
{
// Create default cache metrics instance
$cacheMetrics = new \App\Framework\Cache\Metrics\CacheMetrics();
// If storage is not provided, create a default FileStorage for temp directory
if ($storage === null) {
$storage = new FileStorage(sys_get_temp_dir());
}
$cacheMetrics = new \App\Framework\Cache\Metrics\CacheMetrics($storage);
$this->cache = new \App\Framework\Cache\Metrics\MetricsDecoratedCache(
$this->cache,

View File

@@ -17,6 +17,8 @@ use App\Framework\Cache\Metrics\MetricsDecoratedCache;
use App\Framework\Core\PathProvider;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Filesystem\FileStorage;
use App\Framework\Filesystem\Storage;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Redis\RedisConfig;
use App\Framework\Redis\RedisConnection;
@@ -84,8 +86,20 @@ final readonly class CacheInitializer
#return new GeneralCache(new NullCache(), $serializer, $compression);
// Create cache metrics instance directly to avoid DI circular dependency
$cacheMetrics = new CacheMetrics();
// Create cache metrics instance with storage from container
// Try to use temp storage for metrics persistence, fallback to default storage
try {
$storage = $this->container->get('filesystem.storage.temp');
} catch (\Throwable $e) {
// Fallback to default storage if temp storage is not available
try {
$storage = $this->container->get(Storage::class);
} catch (\Throwable $e2) {
// Last resort: create FileStorage directly with temp directory
$storage = new FileStorage(sys_get_temp_dir());
}
}
$cacheMetrics = new CacheMetrics($storage);
// Bind it to container for other services that might need it
if (! $this->container->has(CacheMetricsInterface::class)) {

View File

@@ -48,7 +48,7 @@ final readonly class ClearCache
$cleared[] = 'Discovery files';
// Clear routes cache
$routesCacheFile = $this->pathProvider->resolvePath('/cache/routes.cache.php');
$routesCacheFile = $this->pathProvider->resolvePath('/cache/routes.cache.php')->toString();
if (file_exists($routesCacheFile)) {
unlink($routesCacheFile);
$cleared[] = 'Routes cache';
@@ -115,7 +115,7 @@ final readonly class ClearCache
private function clearDiscoveryFiles(): void
{
$cacheDir = $this->pathProvider->resolvePath('/cache');
$cacheDir = $this->pathProvider->resolvePath('/cache')->toString();
if (! is_dir($cacheDir)) {
return;
}
@@ -130,7 +130,7 @@ final readonly class ClearCache
private function clearAllCacheFiles(): void
{
$cacheDir = $this->pathProvider->resolvePath('/cache');
$cacheDir = $this->pathProvider->resolvePath('/cache')->toString();
if (! is_dir($cacheDir)) {
return;
}

View File

@@ -25,7 +25,7 @@ final readonly class FileCache implements CacheDriver, Scannable
) {
$this->cachePath = $this->normalizeCachePath(
$cachePath
?? $pathProvider?->getCachePath()
?? $pathProvider?->getCachePath()?->toString()
?? $this->detectCachePath()
);

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Cache\Metrics;
use App\Framework\Filesystem\Storage;
final class CacheMetrics implements CacheMetricsInterface
{
private array $stats = [
@@ -32,8 +34,10 @@ final class CacheMetrics implements CacheMetricsInterface
private int $saveInterval = 30; // Save every 30 seconds
public function __construct(string $persistenceFile = '/tmp/cache_metrics.json')
{
public function __construct(
private readonly Storage $storage,
string $persistenceFile = 'cache_metrics.json'
) {
$this->persistenceFile = $persistenceFile;
$this->loadFromFile();
if ($this->stats['start_time'] === 0) {
@@ -212,12 +216,13 @@ final class CacheMetrics implements CacheMetricsInterface
private function loadFromFile(): void
{
if (! file_exists($this->persistenceFile)) {
if (! $this->storage->exists($this->persistenceFile)) {
return;
}
try {
$data = json_decode(file_get_contents($this->persistenceFile), true);
$content = $this->storage->get($this->persistenceFile);
$data = json_decode($content, true);
if (is_array($data)) {
$this->stats = array_merge($this->stats, $data['stats'] ?? []);
$this->keyStats = $data['keyStats'] ?? [];
@@ -245,7 +250,7 @@ final class CacheMetrics implements CacheMetricsInterface
'keyStats' => $this->keyStats,
'saved_at' => time(),
];
file_put_contents($this->persistenceFile, json_encode($data, JSON_PRETTY_PRINT));
$this->storage->put($this->persistenceFile, json_encode($data, JSON_PRETTY_PRINT));
} catch (\Throwable $e) {
// If saving fails, continue silently
error_log("Failed to save cache metrics: " . $e->getMessage());

View File

@@ -33,6 +33,9 @@ final readonly class CommandBusInitializer
}
$handlersCollection = new CommandHandlersCollection(...$handlers);
// Registriere CommandHandlersCollection im Container für Middleware
$this->container->instance(CommandHandlersCollection::class, $handlersCollection);
// Resolve queue from container to avoid circular dependency
$queue = $this->container->get(Queue::class);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\CommandBus;
use App\Framework\CommandBus\Exceptions\NoHandlerFound;
use App\Framework\CommandBus\Middleware\AttributeExecutionMiddleware;
use App\Framework\CommandBus\Middleware\DatabaseTransactionMiddleware;
use App\Framework\CommandBus\Middleware\PerformanceMonitoringMiddleware;
use App\Framework\Context\ContextType;
@@ -22,6 +23,7 @@ final class DefaultCommandBus implements CommandBus
private Queue $queue,
private Logger $logger,
private array $middlewares = [
AttributeExecutionMiddleware::class,
PerformanceMonitoringMiddleware::class,
DatabaseTransactionMiddleware::class,
],

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Framework\CommandBus\Middleware;
use App\Framework\Attributes\Execution\HandlerAttributeExecutor;
use App\Framework\CommandBus\CommandHandlersCollection;
use App\Framework\CommandBus\Middleware;
/**
* Attribute Execution Middleware für CommandBus
*
* Führt Attribute auf Command-Handler-Methoden aus:
* - BeforeExecute: Vor Handler-Ausführung
* - AfterExecute: Nach erfolgreicher Handler-Ausführung
* - OnError: Bei Exceptions während Handler-Ausführung
*/
final readonly class AttributeExecutionMiddleware implements Middleware
{
public function __construct(
private CommandHandlersCollection $commandHandlers,
private HandlerAttributeExecutor $attributeExecutor
) {
}
public function handle(object $command, callable $next): mixed
{
// Finde Handler für Command
$handler = $this->commandHandlers->get($command::class);
if ($handler === null) {
// Kein Handler gefunden, weiter mit Pipeline
return $next($command);
}
// Führe "Before"-Attribute aus
try {
$this->attributeExecutor->executeBefore(
$handler->class,
$handler->method,
$command
);
} catch (\Throwable $e) {
// Before-Attribute können die Ausführung abbrechen
// Wenn sie eine Exception werfen, wird die Handler-Ausführung übersprungen
$this->attributeExecutor->executeOnError(
$handler->class,
$handler->method,
$command,
$e
);
throw $e;
}
try {
// Führe Handler aus (durch next() in der Pipeline)
$result = $next($command);
// Führe "After"-Attribute aus
try {
$this->attributeExecutor->executeAfter(
$handler->class,
$handler->method,
$command,
$result
);
} catch (\Throwable $e) {
// After-Attribute-Fehler sollten die Handler-Ausführung nicht beeinflussen
// Aber wir loggen sie (könnte über Logger gemacht werden)
// Für jetzt ignorieren wir sie, da der Handler bereits erfolgreich war
}
return $result;
} catch (\Throwable $e) {
// Führe "OnError"-Attribute aus
try {
$this->attributeExecutor->executeOnError(
$handler->class,
$handler->method,
$command,
$e
);
} catch (\Throwable $errorHandlerException) {
// Error-Handler-Fehler sollten die ursprüngliche Exception nicht überschreiben
// Aber wir könnten sie loggen
}
// Wirf die ursprüngliche Exception weiter
throw $e;
}
}
}

View File

@@ -5,28 +5,44 @@ declare(strict_types=1);
namespace App\Framework\CommandBus\Middleware;
use App\Framework\CommandBus\Middleware;
use App\Framework\DI\Container;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
final readonly class PerformanceMonitoringMiddleware implements Middleware
{
public function __construct(
private PerformanceCollectorInterface $collector
private Container $container
) {
}
public function handle(object $command, callable $next): mixed
{
// Performance Monitoring ist optional - nur ausführen wenn Collector verfügbar
if (!$this->container->has(PerformanceCollectorInterface::class)) {
return $next($command);
}
$collector = $this->container->get(PerformanceCollectorInterface::class);
// Prüfe ob Performance Tracking aktiviert ist
if (!$collector->isEnabled()) {
return $next($command);
}
$commandKey = 'command_' . basename(str_replace('\\', '/', $command::class));
$this->collector->startTiming($commandKey, PerformanceCategory::SYSTEM, [
$collector->startTiming($commandKey, PerformanceCategory::SYSTEM, [
'command_class' => $command::class,
]);
$result = $next($command);
$this->collector->endTiming($commandKey);
return $result;
try {
$result = $next($command);
$collector->endTiming($commandKey);
return $result;
} catch (\Throwable $e) {
$collector->endTiming($commandKey);
throw $e;
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Composer\Exception;
use App\Framework\Exception\Core\FilesystemErrorCode;
use App\Framework\Exception\Core\ValidationErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Exception thrown when reading or parsing composer.lock fails
*/
final class ComposerLockException extends FrameworkException
{
public static function fileNotFound(string $path): self
{
return new self(
message: "composer.lock not found at path: {$path}",
code: (int) FilesystemErrorCode::FILE_NOT_FOUND->getNumericCode()
);
}
public static function couldNotRead(string $path, \Throwable $previous): self
{
return new self(
message: "Could not read composer.lock from path: {$path}",
code: (int) FilesystemErrorCode::FILE_READ_FAILED->getNumericCode(),
previous: $previous
);
}
public static function invalidJson(string $path, \Throwable $previous): self
{
return new self(
message: "Invalid JSON in composer.lock at path: {$path}",
code: (int) ValidationErrorCode::INVALID_FORMAT->getNumericCode(),
previous: $previous
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Composer\Exception;
use App\Framework\Exception\Core\FilesystemErrorCode;
use App\Framework\Exception\Core\ValidationErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Exception thrown when reading or parsing composer.json fails
*/
final class ComposerManifestException extends FrameworkException
{
public static function fileNotFound(string $path): self
{
return new self(
message: "composer.json not found at path: {$path}",
code: (int) FilesystemErrorCode::FILE_NOT_FOUND->getNumericCode()
);
}
public static function couldNotRead(string $path, \Throwable $previous): self
{
return new self(
message: "Could not read composer.json from path: {$path}",
code: (int) FilesystemErrorCode::FILE_READ_FAILED->getNumericCode(),
previous: $previous
);
}
public static function invalidJson(string $path, \Throwable $previous): self
{
return new self(
message: "Invalid JSON in composer.json at path: {$path}",
code: (int) ValidationErrorCode::INVALID_FORMAT->getNumericCode(),
previous: $previous
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\Composer\Services;
use App\Framework\Composer\ValueObjects\ComposerLock;
/**
* Service for reading ComposerLock instances
*/
final class ComposerLockReader
{
/**
* Cache of loaded lock files by path
*
* @var array<string, ComposerLock>
*/
private array $cache = [];
/**
* Read composer.lock from file path
*/
public function read(string $path): ComposerLock
{
// Use cache if available
if (isset($this->cache[$path])) {
return $this->cache[$path];
}
$lock = ComposerLock::fromFile($path);
$this->cache[$path] = $lock;
return $lock;
}
/**
* Read composer.lock from project root directory
*/
public function readFromProjectRoot(string $basePath): ComposerLock
{
$path = rtrim($basePath, '/') . '/composer.lock';
return $this->read($path);
}
/**
* Clear the cache for a specific path or all paths
*/
public function clearCache(?string $path = null): void
{
if ($path !== null) {
unset($this->cache[$path]);
} else {
$this->cache = [];
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\Composer\Services;
use App\Framework\Composer\ValueObjects\ComposerManifest;
/**
* Service for reading and caching ComposerManifest instances
*/
final class ComposerManifestReader
{
/**
* Cache of loaded manifests by path
*
* @var array<string, ComposerManifest>
*/
private array $cache = [];
/**
* Read composer.json from file path
*/
public function read(string $path): ComposerManifest
{
// Use cache if available
if (isset($this->cache[$path])) {
return $this->cache[$path];
}
$manifest = ComposerManifest::fromFile($path);
$this->cache[$path] = $manifest;
return $manifest;
}
/**
* Read composer.json from project root directory
*/
public function readFromProjectRoot(string $basePath): ComposerManifest
{
$path = rtrim($basePath, '/') . '/composer.json';
return $this->read($path);
}
/**
* Clear the cache for a specific path or all paths
*/
public function clearCache(?string $path = null): void
{
if ($path !== null) {
unset($this->cache[$path]);
} else {
$this->cache = [];
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Framework\Composer\ValueObjects;
use App\Framework\Composer\Exception\ComposerLockException;
/**
* Immutable Value Object representing composer.lock data
*/
final readonly class ComposerLock
{
/**
* @param array<array<string, mixed>> $packages
* @param array<array<string, mixed>> $packagesDev
* @param array<string, mixed> $platform
* @param array<string, mixed> $platformDev
*/
public function __construct(
public array $packages = [],
public array $packagesDev = [],
public array $platform = [],
public array $platformDev = [],
public ?string $contentHash = null
) {
}
/**
* Create ComposerLock from file path
*/
public static function fromFile(string $path): self
{
if (! file_exists($path)) {
throw ComposerLockException::fileNotFound($path);
}
$content = @file_get_contents($path);
if ($content === false) {
throw ComposerLockException::couldNotRead($path, new \RuntimeException('file_get_contents failed'));
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw ComposerLockException::invalidJson($path, new \JsonException(json_last_error_msg()));
}
return self::fromArray($data);
}
/**
* Create ComposerLock from array data
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
packages: $data['packages'] ?? [],
packagesDev: $data['packages-dev'] ?? [],
platform: $data['platform'] ?? [],
platformDev: $data['platform-dev'] ?? [],
contentHash: $data['content-hash'] ?? null
);
}
/**
* Get all packages (production + dev) as flat array
*
* @return array<array<string, mixed>>
*/
public function getAllPackages(): array
{
return array_merge($this->packages, $this->packagesDev);
}
/**
* Get package by name
*
* @return array<string, mixed>|null
*/
public function getPackage(string $name): ?array
{
// Check production packages first
foreach ($this->packages as $package) {
if (isset($package['name']) && $package['name'] === $name) {
return $package;
}
}
// Check dev packages
foreach ($this->packagesDev as $package) {
if (isset($package['name']) && $package['name'] === $name) {
return $package;
}
}
return null;
}
/**
* Check if package exists
*/
public function hasPackage(string $name): bool
{
return $this->getPackage($name) !== null;
}
/**
* Get packages with their type (production or dev)
*
* @return array<array{name: string, version: string, type: 'production'|'dev'}>
*/
public function getPackagesWithType(): array
{
$result = [];
foreach ($this->packages as $package) {
if (isset($package['name']) && isset($package['version'])) {
$result[] = [
'name' => $package['name'],
'version' => $package['version'],
'type' => 'production',
];
}
}
foreach ($this->packagesDev as $package) {
if (isset($package['name']) && isset($package['version'])) {
$result[] = [
'name' => $package['name'],
'version' => $package['version'],
'type' => 'dev',
];
}
}
return $result;
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Framework\Composer\ValueObjects;
use App\Framework\Composer\Exception\ComposerManifestException;
/**
* Immutable Value Object representing composer.json data
*/
final readonly class ComposerManifest
{
/**
* @param array<string, mixed> $autoload
* @param array<string, mixed> $autoloadDev
* @param array<string, string> $scripts
* @param array<string, string> $require
* @param array<string, string> $requireDev
* @param array<string, mixed> $config
* @param array<string, mixed> $authors
*/
public function __construct(
public string $name,
public ?string $description = null,
public ?string $type = null,
public ?string $license = null,
public array $authors = [],
public array $autoload = [],
public array $autoloadDev = [],
public array $scripts = [],
public array $require = [],
public array $requireDev = [],
public array $config = []
) {
}
/**
* Create ComposerManifest from file path
*/
public static function fromFile(string $path): self
{
if (! file_exists($path)) {
throw ComposerManifestException::fileNotFound($path);
}
$content = @file_get_contents($path);
if ($content === false) {
throw ComposerManifestException::couldNotRead($path, new \RuntimeException('file_get_contents failed'));
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw ComposerManifestException::invalidJson($path, new \JsonException(json_last_error_msg()));
}
return self::fromArray($data);
}
/**
* Create ComposerManifest from array data
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
name: $data['name'] ?? '',
description: $data['description'] ?? null,
type: $data['type'] ?? null,
license: $data['license'] ?? null,
authors: $data['authors'] ?? [],
autoload: $data['autoload'] ?? [],
autoloadDev: $data['autoload-dev'] ?? [],
scripts: $data['scripts'] ?? [],
require: $data['require'] ?? [],
requireDev: $data['require-dev'] ?? [],
config: $data['config'] ?? []
);
}
/**
* Get PSR-4 autoload paths as array mapping namespace prefixes to paths
*
* @return array<string, string>
*/
public function getPsr4AutoloadPaths(): array
{
$paths = [];
if (isset($this->autoload['psr-4']) && is_array($this->autoload['psr-4'])) {
foreach ($this->autoload['psr-4'] as $namespace => $path) {
$paths[(string) $namespace] = (string) $path;
}
}
return $paths;
}
/**
* Get PSR-4 autoload-dev paths as array mapping namespace prefixes to paths
*
* @return array<string, string>
*/
public function getPsr4AutoloadDevPaths(): array
{
$paths = [];
if (isset($this->autoloadDev['psr-4']) && is_array($this->autoloadDev['psr-4'])) {
foreach ($this->autoloadDev['psr-4'] as $namespace => $path) {
$paths[(string) $namespace] = (string) $path;
}
}
return $paths;
}
/**
* Get all scripts defined in composer.json
*
* @return array<string, string>
*/
public function getScripts(): array
{
return $this->scripts;
}
/**
* Check if a script exists
*/
public function hasScript(string $name): bool
{
return isset($this->scripts[$name]);
}
/**
* Get script command by name
*/
public function getScript(string $name): ?string
{
return $this->scripts[$name] ?? null;
}
/**
* Get all dependencies (require + require-dev)
*
* @return array<string, string>
*/
public function getAllDependencies(): array
{
return array_merge($this->require, $this->requireDev);
}
}

View File

@@ -63,11 +63,16 @@ final class DockerSecretsResolver
}
try {
// Normalize file path: if path doesn't start with /run/secrets, prepend it
// Normalize file path: if path doesn't start with /run/secrets or /var/www, prepend /run/secrets
// This handles Docker Swarm secrets which may only provide the secret name
// Example: /redis_password -> /run/secrets/redis_password
if (!str_starts_with($filePath, '/run/secrets/') && str_starts_with($filePath, '/')) {
// Path starts with / but not /run/secrets/, likely a secret name
// BUT: Don't modify absolute paths that are clearly not secret names (like /var/www/html/...)
if (!str_starts_with($filePath, '/run/secrets/') &&
!str_starts_with($filePath, '/var/www/') &&
!str_starts_with($filePath, '/usr/') &&
!str_starts_with($filePath, '/etc/') &&
str_starts_with($filePath, '/')) {
// Path starts with / but not a known absolute path, likely a secret name
$secretName = ltrim($filePath, '/');
$filePath = '/run/secrets/' . $secretName;
}

View File

@@ -63,6 +63,11 @@ enum EnvKey: string
// Vault Configuration
case VAULT_ENCRYPTION_KEY = 'VAULT_ENCRYPTION_KEY';
// SSL/Let's Encrypt Configuration
case DOMAIN_NAME = 'DOMAIN_NAME';
case SSL_EMAIL = 'SSL_EMAIL';
case LETSENCRYPT_STAGING = 'LETSENCRYPT_STAGING';
// Error Reporting Configuration
case ERROR_REPORTING_ENABLED = 'ERROR_REPORTING_ENABLED';
case ERROR_REPORTING_ASYNC = 'ERROR_REPORTING_ASYNC';

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands\Cms;
use App\Domain\Cms\Exceptions\ContentNotFoundException;
use App\Domain\Cms\Services\ContentService;
use App\Domain\Cms\ValueObjects\ContentBlock;
use App\Domain\Cms\ValueObjects\ContentBlockType;
use App\Domain\Cms\ValueObjects\ContentBlocks;
use App\Domain\Cms\ValueObjects\ContentSlug;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\Commands\Command;
use App\Framework\Console\Output\OutputInterface;
#[CommandGroup('cms')]
final readonly class UpdateHomepageBlocksCommand implements Command
{
public function __construct(
private ContentService $contentService
) {
}
public function getName(): string
{
return 'cms:update-homepage-blocks';
}
public function getDescription(): string
{
return 'Updates homepage content with default blocks if it has no blocks';
}
public function execute(array $args, OutputInterface $output): int
{
$slug = ContentSlug::fromString('homepage');
try {
$content = $this->contentService->findBySlug($slug);
if (count($content->blocks) > 0) {
$output->writeln("Homepage content already has " . count($content->blocks) . " block(s). Skipping update.");
return 0;
}
$blocks = ContentBlocks::from([
new ContentBlock(
type: ContentBlockType::fromString('text'),
data: [
'text' => '<h1>Willkommen auf der Homepage</h1><p>Dies ist der Standard-Content für die Homepage.</p>'
]
)
]);
$this->contentService->updateBlocks($content->id, $blocks);
$output->writeln("✅ Homepage content updated with default blocks.");
return 0;
} catch (ContentNotFoundException $e) {
$output->writeln("❌ Homepage content not found. Please run DefaultContentSeeder first.");
return 1;
} catch (\Throwable $e) {
$output->writeln("❌ Error updating homepage content: " . $e->getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\ReturnTypeValue;
use App\Framework\DI\Exceptions\InitializerCycleException;
use App\Framework\DI\Initializer;
use App\Framework\DI\InitializerDependencyAnalyzer;
use App\Framework\DI\InitializerDependencyGraph;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Reflection\ReflectionService;
#[CommandGroup(
name: 'Framework',
description: 'Framework diagnostics and health checks',
icon: '🔧',
priority: 90
)]
final readonly class InitializersCheckCommand
{
public function __construct(
private DiscoveryRegistry $discoveryRegistry,
private ReflectionService $reflectionService,
private InitializerDependencyAnalyzer $dependencyAnalyzer,
private ConsoleOutputInterface $output
) {
}
#[ConsoleCommand('initializers:check', 'Check all initializers for problems (dependencies, cycles, etc.)')]
public function check(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$jsonOutput = $input->hasOption('json');
$output->writeLine('Checking initializers...', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$initializerResults = $this->discoveryRegistry->attributes->get(Initializer::class);
$totalInitializers = count($initializerResults);
if ($totalInitializers === 0) {
$output->writeLine('⚠️ No initializers found in discovery registry.', ConsoleColor::YELLOW);
return ExitCode::FAILURE;
}
$output->writeLine("Found {$totalInitializers} initializer(s)", ConsoleColor::WHITE);
$output->newLine();
// Build dependency graph
$dependencyGraph = new InitializerDependencyGraph(
$this->reflectionService,
$this->dependencyAnalyzer
);
$problems = [];
$warnings = [];
$info = [];
// Phase 1: Analyze each initializer
foreach ($initializerResults as $discoveredAttribute) {
$initializer = $discoveredAttribute->createAttributeInstance();
if ($initializer === null) {
$problems[] = [
'type' => 'error',
'message' => "Failed to instantiate Initializer attribute",
'class' => (string) $discoveredAttribute->className,
'method' => (string) ($discoveredAttribute->methodName ?? 'unknown'),
];
continue;
}
$methodName = $discoveredAttribute->methodName ?? \App\Framework\Core\ValueObjects\MethodName::invoke();
$returnTypeString = $discoveredAttribute->additionalData['return'] ?? null;
try {
$returnType = ReturnTypeValue::fromString($returnTypeString, $discoveredAttribute->className);
// Skip setup initializers (void return)
if ($returnType->hasNoReturn()) {
continue;
}
$concreteReturnType = $returnType->isSelf()
? $returnType->toClassName()
: $returnType->toClassName();
// Analyze dependencies
$analysis = $this->dependencyAnalyzer->analyze($discoveredAttribute->className->getFullyQualified());
// Check for missing dependencies
$missingDeps = [];
foreach (array_merge($analysis['constructorDeps'], $analysis['containerGetDeps']) as $dep) {
// Skip Container itself
if (\App\Framework\DI\InitializerDependencyAnalyzer::isContainerClass($dep)) {
continue;
}
// Check if dependency has an initializer or is a concrete class
if (!class_exists($dep) && !interface_exists($dep)) {
$missingDeps[] = $dep;
}
}
if (!empty($missingDeps)) {
$warnings[] = [
'type' => 'warning',
'message' => "Potential missing dependencies",
'class' => (string) $discoveredAttribute->className,
'method' => (string) $methodName,
'return_type' => $concreteReturnType->getFullyQualified(),
'missing_dependencies' => $missingDeps,
];
}
// Add to graph
// Extrahiere explizite Dependencies und Priority aus additionalData
$explicitDependencies = $discoveredAttribute->additionalData['dependencies'] ?? null;
$priority = (int) ($discoveredAttribute->additionalData['priority'] ?? 0);
$dependencyGraph->addInitializer(
$concreteReturnType->getFullyQualified(),
$discoveredAttribute->className,
$methodName,
$explicitDependencies,
$priority
);
$info[] = [
'type' => 'info',
'class' => (string) $discoveredAttribute->className,
'method' => (string) $methodName,
'return_type' => $concreteReturnType->getFullyQualified(),
'dependencies' => array_merge($analysis['constructorDeps'], $analysis['containerGetDeps']),
];
} catch (\Throwable $e) {
$problems[] = [
'type' => 'error',
'message' => "Failed to analyze initializer: {$e->getMessage()}",
'class' => (string) $discoveredAttribute->className,
'method' => (string) $methodName,
'exception' => get_class($e),
];
}
}
// Phase 2: Check for cycles
try {
$executionOrder = $dependencyGraph->getExecutionOrder();
} catch (InitializerCycleException $e) {
$cycles = $e->getCycles();
$paths = $e->getDependencyPaths();
foreach ($cycles as $index => $cycle) {
$path = $paths[$index] ?? $cycle;
$problems[] = [
'type' => 'error',
'message' => 'Circular dependency detected',
'cycle' => $cycle,
'dependency_path' => $path,
];
}
}
// Phase 3: Output results
if ($jsonOutput) {
$this->outputJson($output, [
'total' => $totalInitializers,
'problems' => $problems,
'warnings' => $warnings,
'info' => $info,
]);
} else {
$this->outputHumanReadable($output, $problems, $warnings, $info, $dependencyGraph);
}
// Return exit code based on problems
if (!empty($problems)) {
return ExitCode::FAILURE;
}
if (!empty($warnings)) {
return ExitCode::PARTIAL_SUCCESS;
}
$output->writeLine('✅ All initializers are valid', ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
private function outputHumanReadable(
ConsoleOutputInterface $output,
array $problems,
array $warnings,
array $info,
InitializerDependencyGraph $graph
): void {
// Show problems
if (!empty($problems)) {
$output->writeLine('❌ Problems:', ConsoleColor::BRIGHT_RED);
foreach ($problems as $problem) {
$output->writeLine("{$problem['message']}", ConsoleColor::RED);
if (isset($problem['class'])) {
$output->writeLine(" Class: {$problem['class']}", ConsoleColor::GRAY);
}
if (isset($problem['cycle'])) {
$cycleStr = implode(' → ', $problem['cycle']) . ' → ' . ($problem['cycle'][0] ?? '');
$output->writeLine(" Cycle: {$cycleStr}", ConsoleColor::GRAY);
}
if (isset($problem['dependency_path'])) {
$pathStr = implode(' → ', $problem['dependency_path']);
$output->writeLine(" Path: {$pathStr}", ConsoleColor::GRAY);
}
}
$output->newLine();
}
// Show warnings
if (!empty($warnings)) {
$output->writeLine('⚠️ Warnings:', ConsoleColor::BRIGHT_YELLOW);
foreach ($warnings as $warning) {
$output->writeLine("{$warning['message']}", ConsoleColor::YELLOW);
$output->writeLine(" Class: {$warning['class']}::{$warning['method']}", ConsoleColor::GRAY);
if (isset($warning['missing_dependencies'])) {
$depsStr = implode(', ', $warning['missing_dependencies']);
$output->writeLine(" Missing: {$depsStr}", ConsoleColor::GRAY);
}
}
$output->newLine();
}
// Show summary
$output->writeLine('📊 Summary:', ConsoleColor::BRIGHT_CYAN);
$output->writeLine(" Total initializers: " . count($info), ConsoleColor::WHITE);
$output->writeLine(" Problems: " . count($problems), ConsoleColor::RED);
$output->writeLine(" Warnings: " . count($warnings), ConsoleColor::YELLOW);
// Show dependency graph visualization
$output->newLine();
$output->writeLine('🔗 Dependency Graph:', ConsoleColor::BRIGHT_CYAN);
try {
$executionOrder = $graph->getExecutionOrder();
foreach ($executionOrder as $index => $returnType) {
$node = $graph->getNode($returnType);
if ($node === null) {
continue;
}
$deps = empty($node->dependencies)
? 'no dependencies'
: 'depends on: ' . implode(', ', $node->dependencies);
$output->writeLine(
sprintf(' %d. %s', $index + 1, $node->toString()),
ConsoleColor::WHITE
);
}
} catch (InitializerCycleException $e) {
$output->writeLine(' ⚠️ Cannot show graph due to circular dependencies', ConsoleColor::YELLOW);
}
}
private function outputJson(ConsoleOutputInterface $output, array $data): void
{
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$output->writeLine($json);
}
}

View File

@@ -13,8 +13,10 @@ use App\Framework\Config\TypedConfiguration;
use App\Framework\Console\ConsoleApplication;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Context\ExecutionContext;
use App\Framework\Core\BootstrapProfile;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Core\RequestLifecycleObserver;
use App\Framework\DI\Container;
use App\Framework\DI\DefaultContainer;
use App\Framework\Encryption\EncryptionFactory;
@@ -22,12 +24,14 @@ use App\Framework\ExceptionHandling\ExceptionHandlerManager;
use App\Framework\ExceptionHandling\ExceptionHandlerManagerFactory;
use App\Framework\Http\MiddlewareManager;
use App\Framework\Http\MiddlewareManagerInterface;
use App\Framework\Http\Request;
use App\Framework\Http\ResponseEmitter;
use App\Framework\Http\ServerEnvironment;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\PerformanceCategory;
use App\Framework\Random\RandomGenerator;
use App\Framework\Router\HttpRouter;
/**
* Verantwortlich für die grundlegende Initialisierung der Anwendung
@@ -64,8 +68,10 @@ final readonly class AppBootstrapper
#
#}
// Make Environment available throughout the application
$this->container->instance(Environment::class, $env);
// Make Environment available throughout the application as singleton
// This ensures Environment is always the same instance and can be safely retrieved
// by ConnectionInitializer and other components without circular dependencies
$this->container->singleton(Environment::class, $env);
$typedConfigInitializer = new TypedConfigInitializer($env);
$typedConfig = $typedConfigInitializer($this->container);
@@ -83,51 +89,35 @@ final readonly class AppBootstrapper
public function bootstrapWeb(): ApplicationInterface
{
$this->bootstrap();
$this->registerWebErrorHandler();
$this->registerApplication();
$mm = $this->container->get(MiddlewareManager::class);
$this->container->instance(MiddlewareManagerInterface::class, $mm);
$ed = $this->container->get(EventDispatcher::class);
$this->container->instance(EventDispatcherInterface::class, $ed);
return $this->container->get(ApplicationInterface::class);
/** @var ApplicationInterface */
return $this->runProfile($this->webProfile());
}
public function bootstrapConsole(): ConsoleApplication
{
$this->bootstrap();
$this->registerCliErrorHandler();
$this->registerConsoleApplication();
return $this->container->get(ConsoleApplication::class);
/** @var ConsoleApplication */
return $this->runProfile($this->consoleProfile());
}
public function bootstrapWorker(): Container
{
$this->bootstrap();
$this->registerCliErrorHandler();
$ed = $this->container->get(EventDispatcher::class);
$this->container->instance(EventDispatcherInterface::class, $ed);
$consoleOutput = new ConsoleOutput();
$this->container->instance(ConsoleOutput::class, $consoleOutput);
return $this->container;
/** @var Container */
return $this->runProfile($this->workerProfile());
}
public function bootstrapWebSocket(): Container
{
/** @var Container */
return $this->runProfile($this->webSocketProfile());
}
private function runProfile(BootstrapProfile $profile): mixed
{
$profile->prepare($this, $this->container);
$this->bootstrap();
$this->registerCliErrorHandler();
$profile->afterBootstrap($this, $this->container);
$consoleOutput = new ConsoleOutput();
$this->container->instance(ConsoleOutput::class, $consoleOutput);
return $this->container;
return $profile->resolve($this, $this->container);
}
private function bootstrap(): void
@@ -167,9 +157,13 @@ final readonly class AppBootstrapper
{
$this->container->singleton(ApplicationInterface::class, function (Container $c) {
return new Application(
$c,
$c->get(TypedConfiguration::class),
$c->get(Request::class),
$c->get(MiddlewareManagerInterface::class),
$c->get(ResponseEmitter::class),
$c->get(TypedConfiguration::class)
$c->get(RequestLifecycleObserver::class),
$c->get(EventDispatcherInterface::class),
$c->get(HttpRouter::class)
);
});
}
@@ -231,4 +225,68 @@ final readonly class AppBootstrapper
error_log("Failed to initialize secrets management: " . $e->getMessage());
}
}
private function webProfile(): BootstrapProfile
{
return new BootstrapProfile(
'web',
prepare: function (self $self, Container $container): void {
$container->instance(ExecutionContext::class, ExecutionContext::forWeb());
},
afterBootstrap: function (self $self, Container $container): void {
$self->registerWebErrorHandler();
$self->registerApplication();
$middlewareManager = $container->get(MiddlewareManager::class);
$container->instance(MiddlewareManagerInterface::class, $middlewareManager);
$eventDispatcher = $container->get(EventDispatcher::class);
$container->instance(EventDispatcherInterface::class, $eventDispatcher);
},
resolve: fn (self $self, Container $container): ApplicationInterface => $container->get(ApplicationInterface::class)
);
}
private function consoleProfile(): BootstrapProfile
{
return new BootstrapProfile(
'console',
prepare: null,
afterBootstrap: function (self $self, Container $container): void {
$self->registerCliErrorHandler();
$self->registerConsoleApplication();
},
resolve: fn (self $self, Container $container): ConsoleApplication => $container->get(ConsoleApplication::class)
);
}
private function workerProfile(): BootstrapProfile
{
return new BootstrapProfile(
'worker',
prepare: null,
afterBootstrap: function (self $self, Container $container): void {
$self->registerCliErrorHandler();
$eventDispatcher = $container->get(EventDispatcher::class);
$container->instance(EventDispatcherInterface::class, $eventDispatcher);
$container->instance(ConsoleOutput::class, new ConsoleOutput());
},
resolve: fn (self $self, Container $container): Container => $container
);
}
private function webSocketProfile(): BootstrapProfile
{
return new BootstrapProfile(
'websocket',
prepare: null,
afterBootstrap: function (self $self, Container $container): void {
$self->registerCliErrorHandler();
$container->instance(ConsoleOutput::class, new ConsoleOutput());
},
resolve: fn (self $self, Container $container): Container => $container
);
}
}

View File

@@ -5,43 +5,48 @@ declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Core\Events\AfterEmitResponse;
use App\Framework\Core\Events\AfterHandleRequest;
use App\Framework\Core\Events\ApplicationBooted;
use App\Framework\Core\Events\BeforeEmitResponse;
use App\Framework\Core\Events\BeforeHandleRequest;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DI\Container;
use App\Framework\Core\RequestLifecycleObserver;
use App\Framework\Http\MiddlewareManagerInterface;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Http\ResponseEmitter;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
use App\Framework\Router\HttpRouter;
use DateTimeImmutable;
final readonly class Application implements ApplicationInterface
{
private MiddlewareManagerInterface $middlewareManager;
private EventDispatcherInterface $eventDispatcher;
private PerformanceCollectorInterface $performanceCollector;
public function __construct(
private Container $container,
private TypedConfiguration $config,
private Request $request,
private MiddlewareManagerInterface $middlewareManager,
private ResponseEmitter $responseEmitter,
private TypedConfiguration $config,
?MiddlewareManagerInterface $middlewareManager = null,
?EventDispatcherInterface $eventDispatcher = null,
?PerformanceCollectorInterface $performanceCollector = null,
) {
// Dependencies optional injizieren oder aus Container holen
$this->middlewareManager = $middlewareManager ?? $this->container->get(MiddlewareManagerInterface::class);
$this->eventDispatcher = $eventDispatcher ?? $this->container->get(EventDispatcherInterface::class);
$this->performanceCollector = $performanceCollector ?? $this->container->get(PerformanceCollectorInterface::class);
private RequestLifecycleObserver $lifecycleObserver,
private EventDispatcherInterface $eventDispatcher,
private HttpRouter $router,
) {}
public function config(string $path, mixed $default = null): mixed
{
$segments = explode('.', $path);
$value = $this->config;
foreach ($segments as $segment) {
if (is_object($value) && isset($value->{$segment})) {
$value = $value->{$segment};
continue;
}
if (is_array($value) && array_key_exists($segment, $value)) {
$value = $value[$segment];
continue;
}
return $default;
}
return $value;
}
/**
@@ -50,8 +55,13 @@ final readonly class Application implements ApplicationInterface
public function run(): void
{
$this->boot();
$response = $this->handleRequest($this->container->get(Request::class));
$this->emitResponse($response);
$this->lifecycleObserver->process(
$this->request,
fn (Request $request): Response => $this->middlewareManager->chain->handle($request),
function (Response $response): void {
$this->responseEmitter->emit($response);
}
);
}
/**
@@ -69,107 +79,14 @@ final readonly class Application implements ApplicationInterface
version: $version
);
$this->event($bootEvent);
$this->eventDispatcher->dispatch($bootEvent);
// Sicherstellen, dass ein Router registriert wurde
if (! $this->container->has(HttpRouter::class)) {
throw new \RuntimeException('Kritischer Fehler: Router wurde nicht initialisiert');
}
$this->ensureRouterInitialized();
}
/**
* Verarbeitet den HTTP-Request
*/
private function handleRequest(Request $request): Response
private function ensureRouterInitialized(): void
{
$this->event(new BeforeHandleRequest(
request: $request,
context: [
'route' => $request->path,
'method' => $request->method->value,
'user_agent' => $request->server->getUserAgent()?->toString(),
'client_ip' => (string) $request->server->getClientIp(),
]
));
$response = $this->performanceCollector->measure(
'handle_request',
PerformanceCategory::SYSTEM,
function () use ($request) {
return $this->middlewareManager->chain->handle($request);
}
);
// Duration aus PerformanceCollector abrufen
$requestMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::SYSTEM);
$processingTime = Duration::zero();
if (isset($requestMetrics['handle_request'])) {
$measurements = $requestMetrics['handle_request']->getMeasurements();
$latestMeasurement = $measurements->getLast();
if ($latestMeasurement !== null) {
$processingTime = $latestMeasurement->getDuration();
}
}
$this->event(new AfterHandleRequest(
request: $request,
response: $response,
processingTime: $processingTime,
context: [
'status_code' => $response->status->value,
'content_length' => strlen($response->body),
'memory_peak' => memory_get_peak_usage(true),
]
));
return $response;
}
/**
* Gibt die HTTP-Response aus
*/
private function emitResponse(Response $response): void
{
$request = $this->container->get(Request::class);
$this->event(new BeforeEmitResponse(
request: $request,
response: $response,
context: [
'content_type' => $response->headers->getFirst('Content-Type', 'text/html'),
'cache_control' => $response->headers->getFirst('Cache-Control', 'no-cache'),
]
));
$this->responseEmitter->emit($response);
// Gesamtzeit aus PerformanceCollector abrufen
$systemMetrics = $this->performanceCollector->getMetrics(PerformanceCategory::SYSTEM);
$totalTime = Duration::zero();
if (isset($systemMetrics['handle_request'])) {
$measurements = $systemMetrics['handle_request']->getMeasurements();
$latestMeasurement = $measurements->getLast();
if ($latestMeasurement !== null) {
$totalTime = $latestMeasurement->getDuration();
}
}
$this->event(new AfterEmitResponse(
request: $request,
response: $response,
totalProcessingTime: $totalTime,
context: [
'bytes_sent' => strlen($response->body),
'final_status' => $response->status->value,
]
));
}
private function event(object $event): void
{
$this->eventDispatcher->dispatch($event);
// Access router so initialization failures bubble up early
$this->router->optimizedRoutes;
}
}
// Deployment test

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\DI\Container;
use Closure;
/**
* Declarative description of a bootstrap profile (web, console, worker, ...).
*
* Each profile may prepare the container before the shared bootstrap, run
* additional steps afterwards, and finally resolve the desired return value.
*/
final readonly class BootstrapProfile
{
/**
* @param Closure(AppBootstrapper, Container):void|null $prepare
* @param Closure(AppBootstrapper, Container):void|null $afterBootstrap
* @param Closure(AppBootstrapper, Container):mixed $resolve
*/
public function __construct(
public string $name,
private ?Closure $prepare,
private ?Closure $afterBootstrap,
private Closure $resolve,
) {
}
public function prepare(AppBootstrapper $bootstrapper, Container $container): void
{
if ($this->prepare !== null) {
($this->prepare)($bootstrapper, $container);
}
}
public function afterBootstrap(AppBootstrapper $bootstrapper, Container $container): void
{
if ($this->afterBootstrap !== null) {
($this->afterBootstrap)($bootstrapper, $container);
}
}
public function resolve(AppBootstrapper $bootstrapper, Container $container): mixed
{
return ($this->resolve)($bootstrapper, $container);
}
}

View File

@@ -27,12 +27,7 @@ final readonly class DynamicRoute implements Route
*/
public function getParameterCollection(): ParameterCollection
{
if ($this->parameterCollection !== null) {
return $this->parameterCollection;
}
// Fallback: Create ParameterCollection from legacy array
// This should not happen in production with proper RouteCompiler
return new ParameterCollection();
return $this->parameterCollection
?? ParameterCollection::fromLegacyArray($this->parameters);
}
}

View File

@@ -4,51 +4,72 @@ declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Composer\Services\ComposerManifestReader;
use App\Framework\Core\ValueObjects\PhpNamespace;
use App\Framework\Filesystem\ValueObjects\FilePath;
/**
* Optimierter PathProvider mit Caching
*/
final class PathProvider
{
private readonly string $basePath;
private readonly FilePath $basePath;
/**
* Cache of resolved paths
*
* @var array<string, FilePath>
*/
private array $resolvedPaths = [];
/**
* Cache of namespace paths
*
* @var array<string, FilePath>|null
*/
private ?array $namespacePaths = null {
get {
if ($this->namespacePaths !== null) {
return $this->namespacePaths;
}
// Aus composer.json auslesen
$composerJsonPath = $this->basePath . '/composer.json';
if (! file_exists($composerJsonPath)) {
return $this->namespacePaths = [];
}
// Use ComposerManifestReader to read composer.json
try {
$manifest = $this->manifestReader->readFromProjectRoot($this->basePath->toString());
$psr4Paths = $manifest->getPsr4AutoloadPaths();
$this->namespacePaths = [];
$composerJson = json_decode(file_get_contents($composerJsonPath), true);
$this->namespacePaths = [];
if (isset($composerJson['autoload']['psr-4'])) {
foreach ($composerJson['autoload']['psr-4'] as $namespace => $path) {
foreach ($psr4Paths as $namespace => $path) {
$this->namespacePaths[$namespace] = $this->resolvePath($path);
}
}
return $this->namespacePaths;
return $this->namespacePaths;
} catch (\Throwable $e) {
// If composer.json doesn't exist or can't be read, return empty array
return $this->namespacePaths = [];
}
}
}
public function __construct(string $basePath)
{
$this->basePath = rtrim($basePath, '/');
private readonly ComposerManifestReader $manifestReader;
public function __construct(
string $basePath,
?ComposerManifestReader $manifestReader = null
) {
// Normalize basePath: remove trailing slash, but keep '/' if it's the root
$normalizedBasePath = rtrim($basePath, '/');
if ($normalizedBasePath === '') {
$normalizedBasePath = '/';
}
$this->basePath = FilePath::create($normalizedBasePath);
$this->manifestReader ??= new ComposerManifestReader();
}
/**
* Gibt den Basispfad des Projekts zurück
*/
public function getBasePath(): string
public function getBasePath(): FilePath
{
return $this->basePath;
}
@@ -56,13 +77,13 @@ final class PathProvider
/**
* Löst einen relativen Pfad zum Basispfad auf
*/
public function resolvePath(string $relativePath): string
public function resolvePath(string $relativePath): FilePath
{
if (isset($this->resolvedPaths[$relativePath])) {
return $this->resolvedPaths[$relativePath];
}
$path = $this->basePath . '/' . ltrim($relativePath, '/');
$path = $this->basePath->join($relativePath);
$this->resolvedPaths[$relativePath] = $path;
return $path;
@@ -73,13 +94,13 @@ final class PathProvider
*/
public function pathExists(string $relativePath): bool
{
return file_exists($this->resolvePath($relativePath));
return $this->resolvePath($relativePath)->exists();
}
/**
* Konvertiert einen Namespace in einen relativen Pfad
*/
public function namespaceToPath(string|PhpNamespace $namespace): ?string
public function namespaceToPath(string|PhpNamespace $namespace): ?FilePath
{
$namespaceStr = $namespace instanceof PhpNamespace ? $namespace->toString() : $namespace;
$namespacePaths = $this->namespacePaths;
@@ -90,7 +111,7 @@ final class PathProvider
$relativeNamespace = substr($namespaceStr, strlen($prefix));
$relativePath = str_replace('\\', '/', $relativeNamespace);
return rtrim($path, '/') . '/' . $relativePath . '.php';
return $path->join($relativePath . '.php');
}
}
@@ -100,7 +121,7 @@ final class PathProvider
/**
* Konvertiert einen Pfad in einen Namespace (als String)
*/
public function pathToNamespace(string $path): ?string
public function pathToNamespace(string|FilePath $path): ?string
{
$namespace = $this->pathToNamespaceObject($path);
return $namespace?->toString();
@@ -109,10 +130,11 @@ final class PathProvider
/**
* Konvertiert einen Pfad in einen Namespace (als Value Object)
*/
public function pathToNamespaceObject(string $path): ?PhpNamespace
public function pathToNamespaceObject(string|FilePath $path): ?PhpNamespace
{
$namespacePaths = $this->namespacePaths;
$absolutePath = realpath($path);
$pathString = $path instanceof FilePath ? $path->toString() : $path;
$absolutePath = realpath($pathString);
if (! $absolutePath) {
return null;
@@ -120,7 +142,7 @@ final class PathProvider
// Finde den passenden Pfad-Präfix
foreach ($namespacePaths as $namespace => $nsPath) {
$realNsPath = realpath($nsPath);
$realNsPath = realpath($nsPath->toString());
if ($realNsPath && str_starts_with($absolutePath, $realNsPath)) {
$relativePath = substr($absolutePath, strlen($realNsPath));
@@ -138,41 +160,69 @@ final class PathProvider
/**
* Gibt den Pfad zum Cache-Verzeichnis zurück
*/
public function getCachePath(string $path = ''): string
public function getCachePath(string $path = ''): FilePath
{
return $this->basePath . '/storage/cache/' . ltrim($path, '/');
$parts = ['storage', 'cache'];
if ($path !== '') {
$parts[] = $path;
}
return $this->basePath->join(...$parts);
}
public function getLogPath(string $path = ''): string
public function getLogPath(string $path = ''): FilePath
{
return $this->basePath . '/storage/logs/' . ltrim($path, '/');
$parts = ['storage', 'logs'];
if ($path !== '') {
$parts[] = $path;
}
return $this->basePath->join(...$parts);
}
public function getTempPath(string $path = ''): string
public function getTempPath(string $path = ''): FilePath
{
return $this->basePath . '/storage/temp/' . ltrim($path, '/');
$parts = ['storage', 'temp'];
if ($path !== '') {
$parts[] = $path;
}
return $this->basePath->join(...$parts);
}
public function getUploadsPath(string $path = ''): string
public function getUploadsPath(string $path = ''): FilePath
{
return $this->basePath . '/storage/uploads/' . ltrim($path, '/');
$parts = ['storage', 'uploads'];
if ($path !== '') {
$parts[] = $path;
}
return $this->basePath->join(...$parts);
}
/**
* Gibt den Pfad zum Quellverzeichnis zurück
*/
public function getSourcePath(string $path = ''): string
public function getSourcePath(string $path = ''): FilePath
{
return $this->basePath . '/src/' . ltrim($path, '/');
$parts = ['src'];
if ($path !== '') {
$parts[] = $path;
}
return $this->basePath->join(...$parts);
}
public function getPublicPath(string $path = ''): string
public function getPublicPath(string $path = ''): FilePath
{
return $this->basePath . '/public/' . ltrim($path, '/');
$parts = ['public'];
if ($path !== '') {
$parts[] = $path;
}
return $this->basePath->join(...$parts);
}
public function getStoragePath(string $path = ''): string
public function getStoragePath(string $path = ''): FilePath
{
return $this->basePath . '/storage/' . ltrim($path, '/');
$parts = ['storage'];
if ($path !== '') {
$parts[] = $path;
}
return $this->basePath->join(...$parts);
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Core\Events\AfterEmitResponse;
use App\Framework\Core\Events\AfterHandleRequest;
use App\Framework\Core\Events\BeforeEmitResponse;
use App\Framework\Core\Events\BeforeHandleRequest;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\RequestLifecycleResult;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Performance\PerformanceCategory;
/**
* Observes the HTTP request lifecycle to centralize metric gathering and event dispatching.
*/
final readonly class RequestLifecycleObserver
{
private const HANDLE_REQUEST_KEY = 'handle_request';
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private PerformanceCollectorInterface $performanceCollector,
) {
}
/**
* Fully observes a request lifecycle by handling and emitting the response.
*
* @param callable(Request): Response $requestHandler
* @param callable(Response): void $responseEmitter
*/
public function process(
Request $request,
callable $requestHandler,
callable $responseEmitter
): RequestLifecycleResult {
$result = $this->handle($request, $requestHandler);
$this->emit($request, $result->response, $responseEmitter, $result->processingTime);
return $result;
}
/**
* @param callable(Request): Response $requestHandler
*/
private function handle(Request $request, callable $requestHandler): RequestLifecycleResult
{
$this->eventDispatcher->dispatch(new BeforeHandleRequest(
request: $request,
context: [
'route' => $request->path,
'method' => $request->method->value,
'user_agent' => $request->server->getUserAgent()?->toString(),
'client_ip' => (string) $request->server->getClientIp(),
]
));
$response = $this->performanceCollector->measure(
self::HANDLE_REQUEST_KEY,
PerformanceCategory::SYSTEM,
static fn () => $requestHandler($request)
);
$processingTime = $this->latestMeasurementDuration();
$this->eventDispatcher->dispatch(new AfterHandleRequest(
request: $request,
response: $response,
processingTime: $processingTime,
context: [
'status_code' => $response->status->value,
'content_length' => strlen($response->body),
'memory_peak' => memory_get_peak_usage(true),
]
));
return new RequestLifecycleResult($response, $processingTime);
}
/**
* @param callable(Response): void $responseEmitter
*/
private function emit(
Request $request,
Response $response,
callable $responseEmitter,
Duration $processingTime
): void {
$this->eventDispatcher->dispatch(new BeforeEmitResponse(
request: $request,
response: $response,
context: [
'content_type' => $response->headers->getFirst('Content-Type', 'text/html'),
'cache_control' => $response->headers->getFirst('Cache-Control', 'no-cache'),
]
));
$responseEmitter($response);
$this->eventDispatcher->dispatch(new AfterEmitResponse(
request: $request,
response: $response,
totalProcessingTime: $processingTime,
context: [
'bytes_sent' => strlen($response->body),
'final_status' => $response->status->value,
]
));
}
private function latestMeasurementDuration(): Duration
{
$metrics = $this->performanceCollector->getMetrics(PerformanceCategory::SYSTEM);
if (! isset($metrics[self::HANDLE_REQUEST_KEY])) {
return Duration::zero();
}
$measurements = $metrics[self::HANDLE_REQUEST_KEY]->getMeasurements();
$latest = $measurements->getLast();
if ($latest === null) {
return Duration::zero();
}
return $latest->getDuration();
}
}

View File

@@ -10,6 +10,10 @@ use App\Framework\Router\CompiledPattern;
use App\Framework\Router\CompiledRoutes;
use App\Framework\Router\RouteData;
use App\Framework\Router\RouteNameInterface;
use App\Framework\Core\RouteFactory\DynamicRouteFactory;
use App\Framework\Core\RouteFactory\StaticRouteFactory;
use App\Framework\Router\Services\ParameterCollectionReflector;
use App\Framework\Router\Services\ParameterValidator;
use App\Framework\Router\ValueObjects\MethodParameter;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\SubdomainPattern;
@@ -19,6 +23,18 @@ final readonly class RouteCompiler
/** @var array<string, StaticRoute|DynamicRoute> */
private array $named;
/**
* @param ParameterCollectionResolutionStrategy[] $resolutionStrategies
*/
public function __construct(
private ParameterCollectionReflector $reflector,
private ParameterValidator $validator,
private array $resolutionStrategies,
private StaticRouteFactory $staticRouteFactory,
private DynamicRouteFactory $dynamicRouteFactory
) {
}
/**
* Compile routes directly from DiscoveredAttribute objects with subdomain support
* @return array<string, array<string, array{static: array<string, StaticRoute>, dynamic: array<int, DynamicRoute>}>>
@@ -27,6 +43,8 @@ final readonly class RouteCompiler
{
$compiled = [];
$named = [];
// Cache ParameterCollection per DiscoveredAttribute to avoid duplicate calls
$parameterCollectionCache = [];
foreach ($discoveredRoutes as $discoveredAttribute) {
// Create actual Route attribute instance
@@ -48,6 +66,13 @@ final readonly class RouteCompiler
$subdomainPatterns = [new SubdomainPattern('')];
}
// Get ParameterCollection once per DiscoveredAttribute (cached)
$attributeKey = $discoveredAttribute->getUniqueId();
if (!isset($parameterCollectionCache[$attributeKey])) {
$parameterCollectionCache[$attributeKey] = $this->getParameterCollection($discoveredAttribute);
}
$parameterCollection = $parameterCollectionCache[$attributeKey];
foreach ($subdomainPatterns as $subdomainPattern) {
$subdomainKey = $subdomainPattern->getCompilationKey();
@@ -56,15 +81,13 @@ final readonly class RouteCompiler
if (! str_contains($path, '{')) {
// Static route
$parameterCollection = $this->createParameterCollection($discoveredAttribute->additionalData['parameters'] ?? []);
$staticRoute = new StaticRoute(
controller : $discoveredAttribute->className->getFullyQualified(),
action : $discoveredAttribute->methodName?->toString() ?? '',
parameters : $discoveredAttribute->additionalData['parameters'] ?? [],
name : $routeName ?? '',
path : $path,
attributes : $discoveredAttribute->additionalData['attributes'] ?? [],
parameterCollection: $parameterCollection
$staticRoute = $this->staticRouteFactory->create(
attribute: $discoveredAttribute,
path: $path,
method: $method,
parameterCollection: $parameterCollection,
routeName: $routeName ?? '',
subdomainPattern: $subdomainPattern
);
$compiled[$method][$subdomainKey]['static'][$path] = $staticRoute;
@@ -74,21 +97,13 @@ final readonly class RouteCompiler
}
} else {
// Dynamic route
$paramNames = [];
$regex = $this->convertPathToRegex($path, $paramNames);
$parameterCollection = $this->createParameterCollection($discoveredAttribute->additionalData['parameters'] ?? []);
$dynamicRoute = new DynamicRoute(
regex : $regex,
paramNames : $paramNames,
controller : $discoveredAttribute->className->getFullyQualified(),
action : $discoveredAttribute->methodName?->toString() ?? '',
parameters : $discoveredAttribute->additionalData['parameters'] ?? [],
paramValues : [],
name : $routeName ?? '',
path : $path,
attributes : $discoveredAttribute->additionalData['attributes'] ?? [],
parameterCollection: $parameterCollection
$dynamicRoute = $this->dynamicRouteFactory->create(
attribute: $discoveredAttribute,
path: $path,
method: $method,
parameterCollection: $parameterCollection,
routeName: $routeName ?? '',
subdomainPattern: $subdomainPattern
);
$compiled[$method][$subdomainKey]['dynamic'][] = $dynamicRoute;
@@ -108,23 +123,25 @@ final readonly class RouteCompiler
}
/**
* Convert legacy parameters array to ParameterCollection
* Get ParameterCollection from DiscoveredAttribute
*
* Uses Strategy Pattern to try multiple resolution strategies in order
*/
private function createParameterCollection(array $parameters): ParameterCollection
private function getParameterCollection(\App\Framework\Discovery\ValueObjects\DiscoveredAttribute $discoveredAttribute): ParameterCollection
{
$methodParameters = [];
foreach ($parameters as $param) {
$methodParameters[] = new MethodParameter(
name: $param['name'],
type: $param['type'],
isBuiltin: $param['isBuiltin'],
hasDefault: $param['hasDefault'],
default: $param['default'] ?? null
);
foreach ($this->resolutionStrategies as $strategy) {
if ($strategy->canResolve($discoveredAttribute)) {
$result = $strategy->resolve($discoveredAttribute);
// Only return if we got a valid result (non-empty or valid structure)
// Empty collection from serialized strategy means deserialization failed, try next
if (!$result->isEmpty() || $strategy instanceof \App\Framework\Router\Services\ParameterCollectionResolution\ReflectionParameterCollectionStrategy) {
return $result;
}
}
}
return new ParameterCollection(...$methodParameters);
// Fallback: return empty collection
return new ParameterCollection();
}
/**

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core;
use App\Framework\Core\RouteFactory\DynamicRouteFactory;
use App\Framework\Core\RouteFactory\StaticRouteFactory;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Router\Services\ParameterCollectionReflector;
use App\Framework\Router\Services\ParameterCollectionResolution\LegacyArrayParameterCollectionStrategy;
use App\Framework\Router\Services\ParameterCollectionResolution\ObjectParameterCollectionStrategy;
use App\Framework\Router\Services\ParameterCollectionResolution\ParameterCollectionResolutionStrategy;
use App\Framework\Router\Services\ParameterCollectionResolution\ReflectionParameterCollectionStrategy;
use App\Framework\Router\Services\ParameterCollectionResolution\SerializedParameterCollectionStrategy;
use App\Framework\Router\Services\ParameterValidator;
/**
* Initializer for RouteCompiler
* Creates RouteCompiler with resolution strategies
*/
final readonly class RouteCompilerInitializer
{
public function __construct(
private Container $container
) {
}
#[Initializer]
public function __invoke(
ParameterCollectionReflector $reflector,
ParameterValidator $validator
): RouteCompiler
{
// Create resolution strategies in order of priority
$strategies = [
new ObjectParameterCollectionStrategy(),
new SerializedParameterCollectionStrategy(),
new LegacyArrayParameterCollectionStrategy($validator),
new ReflectionParameterCollectionStrategy($reflector),
];
// Create route factories
$staticRouteFactory = new StaticRouteFactory();
$dynamicRouteFactory = new DynamicRouteFactory();
return new RouteCompiler(
reflector: $reflector,
validator: $validator,
resolutionStrategies: $strategies,
staticRouteFactory: $staticRouteFactory,
dynamicRouteFactory: $dynamicRouteFactory
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\RouteFactory;
use App\Framework\Core\DynamicRoute;
use App\Framework\Core\Route;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\SubdomainPattern;
/**
* Factory for creating DynamicRoute instances
*/
final readonly class DynamicRouteFactory implements RouteFactory
{
/**
* Convert path to regex and extract parameter names
*
* @param string $path
* @param array<string> &$paramNames
* @return string
*/
private function convertPathToRegex(string $path, array &$paramNames): string
{
$paramNames = [];
$regex = preg_replace_callback('#\{(\w+)(\*)?}#', function ($matches) use (&$paramNames) {
$paramNames[] = $matches[1];
// Wenn {id*} dann erlaube Slashes, aber mache es non-greedy
if (isset($matches[2]) && $matches[2] === '*') {
return '(.+?)'; // Non-greedy: matcht so wenig wie möglich
}
return '([^/]+)'; // Keine Slashes
}, $path);
return '~^' . $regex . '$~';
}
public function create(
DiscoveredAttribute $attribute,
string $path,
string $method,
ParameterCollection $parameterCollection,
string $routeName,
SubdomainPattern $subdomainPattern
): Route {
$paramNames = [];
$regex = $this->convertPathToRegex($path, $paramNames);
return new DynamicRoute(
regex: $regex,
paramNames: $paramNames,
controller: $attribute->className->getFullyQualified(),
action: $attribute->methodName?->toString() ?? '',
parameters: $attribute->additionalData['parameters'] ?? [],
paramValues: [],
name: $routeName,
path: $path,
attributes: $attribute->additionalData['attributes'] ?? [],
parameterCollection: $parameterCollection
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\RouteFactory;
use App\Framework\Core\Route;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\SubdomainPattern;
/**
* Interface for Route factories
*/
interface RouteFactory
{
/**
* Create a Route from DiscoveredAttribute
*
* @param DiscoveredAttribute $attribute
* @param string $path
* @param string $method
* @param ParameterCollection $parameterCollection
* @param string $routeName
* @param SubdomainPattern $subdomainPattern
* @return Route
*/
public function create(
DiscoveredAttribute $attribute,
string $path,
string $method,
ParameterCollection $parameterCollection,
string $routeName,
SubdomainPattern $subdomainPattern
): Route;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\RouteFactory;
use App\Framework\Core\Route;
use App\Framework\Core\StaticRoute;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\SubdomainPattern;
/**
* Factory for creating StaticRoute instances
*/
final readonly class StaticRouteFactory implements RouteFactory
{
public function create(
DiscoveredAttribute $attribute,
string $path,
string $method,
ParameterCollection $parameterCollection,
string $routeName,
SubdomainPattern $subdomainPattern
): Route {
return new StaticRoute(
controller: $attribute->className->getFullyQualified(),
action: $attribute->methodName?->toString() ?? '',
parameters: $attribute->additionalData['parameters'] ?? [],
name: $routeName,
path: $path,
attributes: $attribute->additionalData['attributes'] ?? [],
parameterCollection: $parameterCollection
);
}
}

View File

@@ -24,12 +24,7 @@ final readonly class StaticRoute implements Route
*/
public function getParameterCollection(): ParameterCollection
{
if ($this->parameterCollection !== null) {
return $this->parameterCollection;
}
// Fallback: Create ParameterCollection from legacy array
// This should not happen in production with proper RouteCompiler
return new ParameterCollection();
return $this->parameterCollection
?? ParameterCollection::fromLegacyArray($this->parameters);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\Core\ValueObjects;
use App\Framework\Http\Response;
/**
* Captures the result of handling a request including the response and timing information.
*/
final readonly class RequestLifecycleResult
{
public function __construct(
public Response $response,
public Duration $processingTime,
) {
}
}

View File

@@ -23,6 +23,15 @@ interface Container
/** @param class-string $class */
public function has(ClassName|Stringable|string $class): bool;
/**
* Check if an instance already exists (has been instantiated)
* This is different from has() which checks if a binding exists.
*
* @param class-string $class
* @return bool True if instance exists, false otherwise
*/
public function hasInstance(ClassName|Stringable|string $class): bool;
public function bind(
ClassName|Stringable|string $abstract,
callable|string|object $concrete

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\ReflectionLegacy\ReflectionProvider;
/**
* Coordinates flushing/clearing of caches related to the DI container.
*/
final readonly class ContainerCacheManager
{
public function __construct(
private ReflectionProvider $reflectionProvider,
private DependencyResolver $dependencyResolver,
private LazyInstantiator $lazyInstantiator,
) {
}
public function clear(ClassName $className): void
{
$this->reflectionProvider->forget($className);
$this->dependencyResolver->clearCache($className);
$this->lazyInstantiator->forgetFactory($className->getFullyQualified());
}
public function flushAll(): void
{
$this->reflectionProvider->flush();
$this->dependencyResolver->flushCache();
$this->lazyInstantiator->flushFactories();
}
}

View File

@@ -8,13 +8,10 @@ use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\DI\Exceptions\ClassNotInstantiable;
use App\Framework\DI\Exceptions\ClassNotResolvableException;
use App\Framework\DI\Exceptions\ClassResolutionException;
use App\Framework\DI\ProactiveInitializerFinder;
use App\Framework\DI\ValueObjects\InitializerInfo;
use App\Framework\DI\InterfaceInitializerResolver;
use App\Framework\DI\Exceptions\ContainerException;
use App\Framework\DI\Exceptions\CyclicDependencyException;
use App\Framework\DI\Exceptions\LazyLoadingException;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\DI\FailedInitializerRegistry;
use App\Framework\DI\ValueObjects\FailedInitializer;
use App\Framework\Logging\Logger;
@@ -26,25 +23,29 @@ use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\ReflectionProvider;
use Stringable;
use Throwable;
use App\Framework\DI\ResolutionPipeline;
final class DefaultContainer implements Container
{
/** @var class-string[] */
private array $resolving = [];
private readonly DependencyResolver $dependencyResolver;
private readonly SingletonDetector $singletonDetector;
private readonly LazyInstantiator $lazyInstantiator;
private ?ResolutionPipeline $resolutionPipeline = null;
private readonly InterfaceInitializerResolver $initializerResolver;
public readonly MethodInvoker $invoker;
public readonly ContainerIntrospector $introspector;
public readonly FrameworkMetricsCollector $metrics;
private readonly InitializerDependencyAnalyzer $dependencyAnalyzer;
public readonly InitializerDependencyAnalyzer $dependencyAnalyzer;
private readonly ContainerCacheManager $cacheManager;
public function __construct(
private readonly InstanceRegistry $instances = new InstanceRegistry(),
@@ -64,16 +65,39 @@ final class DefaultContainer implements Container
$this->instances,
$this->bindings,
$this->reflectionProvider,
fn (): array => $this->resolving
fn (): array => $this->resolutionPipeline?->getResolvingStack() ?? []
);
$this->metrics = new FrameworkMetricsCollector();
$this->dependencyAnalyzer = new InitializerDependencyAnalyzer($this);
$this->resolutionPipeline = new ResolutionPipeline(
$this->instances,
$this->singletonDetector,
$this->metrics,
$this->dependencyAnalyzer
);
$this->cacheManager = new ContainerCacheManager(
$this->reflectionProvider,
$this->dependencyResolver,
$this->lazyInstantiator
);
$this->registerSelf();
$this->instance(ReflectionProvider::class, $this->reflectionProvider);
// Register new ReflectionService (parallel to ReflectionLegacy for migration)
$this->instance(ReflectionService::class, new SimpleReflectionService());
// Register ProactiveInitializerFinderFactory as singleton
$finderFactory = new ProactiveInitializerFinderFactory($this, $this->reflectionProvider);
$this->singleton(
ProactiveInitializerFinderFactory::class,
fn(Container $container) => $finderFactory
);
$this->initializerResolver = new InterfaceInitializerResolver(
$this,
$this->reflectionProvider,
$finderFactory
);
}
public function bind(ClassName|Stringable|string $abstract, callable|string|object $concrete): void
@@ -90,6 +114,17 @@ final class DefaultContainer implements Container
public function singleton(ClassName|Stringable|string $abstract, callable|string|object $concrete): void
{
$abstract = (string) $abstract;
// Wenn $concrete bereits ein Objekt ist, direkt als Singleton setzen
// Dies ermöglicht es, bestehende Instanzen zu überschreiben
if (is_object($concrete) && !is_callable($concrete)) {
// WICHTIG: Überschreibe bestehende Instanzen (sowohl Singleton als auch normale Instanzen)
$this->instances->forget($abstract);
$this->instances->setSingleton($abstract, $concrete);
return;
}
// Für Factories/Callables/String: Binding erstellen und als Singleton markieren
$this->bind($abstract, $concrete);
$this->instances->markAsSingleton($abstract);
}
@@ -152,38 +187,10 @@ final class DefaultContainer implements Container
*/
private function createInstance(string $class): object
{
if (in_array($class, $this->resolving, true)) {
throw new CyclicDependencyException(
dependencyChain: $this->resolving,
class: $class,
code: 0,
previous: null,
dependencyAnalyzer: $this->dependencyAnalyzer
);
}
$this->resolving[] = $class;
try {
$instance = $this->buildInstance($class);
// Only check singleton attribute for actual classes
if ((class_exists($class) || interface_exists($class)) &&
$this->singletonDetector->isSingleton(ClassName::create($class))) {
$this->instances->setSingleton($class, $instance);
} else {
$this->instances->setInstance($class, $instance);
}
return $instance;
} catch (Throwable $e) {
// Track resolution failures
$this->metrics->increment('container.resolve.failures');
throw $e;
} finally {
array_pop($this->resolving);
}
return $this->resolutionPipeline->resolve(
$class,
fn (string $target): object => $this->buildInstance($target)
);
}
private function buildInstance(string $class): object
@@ -210,7 +217,7 @@ final class DefaultContainer implements Container
// Check if class is instantiable using the framework's method
if (! $classInfo->isInstantiable()) {
// Proaktive Suche nach Initializern für Interfaces
if ($classInfo->getNativeClass()->isInterface() && $this->tryFindAndRegisterInitializer($class)) {
if ($classInfo->getNativeClass()->isInterface() && $this->initializerResolver->tryRegister($class)) {
// Initializer gefunden und registriert - versuche erneut zu resolven
return $this->get($class);
}
@@ -230,7 +237,7 @@ final class DefaultContainer implements Container
class: $class,
exception: $e,
availableBindings: array_keys($this->bindings->getAllBindings()),
dependencyChain: $this->resolving
dependencyChain: $this->resolutionPipeline->getResolvingStack()
);
}
}
@@ -279,7 +286,7 @@ final class DefaultContainer implements Container
// Spezielle Behandlung für Interfaces: Suche nach Initializern die dieses Interface zurückgeben
if ($isInterface) {
foreach ($initializerResults as $initializer) {
$returnType = $this->getInitializerReturnType($initializer);
$returnType = $this->initializerResolver->getInitializerReturnType($initializer);
if ($returnType === $class) {
$matchingInitializers[] = $initializer->className->getFullyQualified();
}
@@ -316,7 +323,7 @@ final class DefaultContainer implements Container
throw ClassNotInstantiable::fromContainerContext(
class: $class,
dependencyChain: $this->resolving,
dependencyChain: $this->resolutionPipeline->getResolvingStack(),
availableBindings: $availableBindings,
discoveredInitializers: $discoveredInitializers,
matchingInitializers: $matchingInitializers,
@@ -373,7 +380,7 @@ final class DefaultContainer implements Container
if (! empty($initializerResults)) {
foreach ($initializerResults as $initializer) {
$returnType = $this->getInitializerReturnType($initializer);
$returnType = $this->initializerResolver->getInitializerReturnType($initializer);
if ($returnType === $class) {
$matchingInitializer = $initializer->className->getFullyQualified();
break;
@@ -390,7 +397,7 @@ final class DefaultContainer implements Container
class: $class,
previous: $e,
availableBindings: array_keys($this->bindings->getAllBindings()),
dependencyChain: $this->resolving,
dependencyChain: $this->resolutionPipeline->getResolvingStack(),
bindingType: $bindingType,
failedInitializer: $failedInitializer,
matchingInitializer: $matchingInitializer
@@ -408,6 +415,22 @@ final class DefaultContainer implements Container
|| $this->canAutoWire($class);
}
/**
* Check if an instance already exists (has been instantiated)
* This is different from has() which checks if a binding exists.
*
* @param class-string $class
* @return bool True if instance exists, false otherwise
*/
public function hasInstance(ClassName|Stringable|string $class): bool
{
$className = (string) $class;
// Check both singleton registry and instance registry
// getSingleton() returns null if not set, so we check if it's actually instantiated
$singleton = $this->instances->getSingleton($className);
return ($singleton !== null) || $this->instances->hasInstance($className);
}
/**
* Prüft ob eine Klasse automatisch instanziiert werden kann (auto-wiring)
* @param string $class Klassenname
@@ -444,9 +467,7 @@ final class DefaultContainer implements Container
{
$this->instances->flush();
$this->bindings->flush();
$this->reflectionProvider->flush();
$this->dependencyResolver->flushCache();
$this->lazyInstantiator->flushFactories();
$this->cacheManager->flushAll();
// Container selbst wieder registrieren
$this->registerSelf();
@@ -471,9 +492,7 @@ final class DefaultContainer implements Container
private function clearCaches(ClassName $className): void
{
$this->reflectionProvider->forget($className);
$this->dependencyResolver->clearCache($className);
$this->lazyInstantiator->forgetFactory($className->getFullyQualified());
$this->cacheManager->clear($className);
}
private function registerSelf(): void
@@ -483,148 +502,4 @@ final class DefaultContainer implements Container
$this->instances->setSingleton(Container::class, $this);
}
/**
* Lazy-getter für ProactiveInitializerFinder
*/
private function getProactiveFinder(): ProactiveInitializerFinder
{
// Erstelle Finder nur wenn benötigt
$discoveryRegistry = $this->has(DiscoveryRegistry::class) ? $this->get(DiscoveryRegistry::class) : null;
if ($discoveryRegistry === null) {
// Fallback: Leere Registry erstellen
$discoveryRegistry = \App\Framework\Discovery\Results\DiscoveryRegistry::empty();
}
$fileScanner = $this->has(\App\Framework\Filesystem\FileScanner::class)
? $this->get(\App\Framework\Filesystem\FileScanner::class)
: new \App\Framework\Filesystem\FileScanner(null, null, new \App\Framework\Filesystem\FileSystemService());
$fileSystemService = new \App\Framework\Filesystem\FileSystemService();
$classExtractor = new \App\Framework\Discovery\Processing\ClassExtractor($fileSystemService);
$pathProvider = $this->has(\App\Framework\Core\PathProvider::class)
? $this->get(\App\Framework\Core\PathProvider::class)
: new \App\Framework\Core\PathProvider(getcwd() ?: '.');
return new ProactiveInitializerFinder(
reflectionProvider: $this->reflectionProvider,
discoveryRegistry: $discoveryRegistry,
fileScanner: $fileScanner,
classExtractor: $classExtractor,
pathProvider: $pathProvider
);
}
/**
* Versucht proaktiv einen Initializer für ein Interface zu finden und zu registrieren
*
* @param string $interface Interface-Klasse
* @return bool True wenn Initializer gefunden und registriert wurde, false sonst
*/
private function tryFindAndRegisterInitializer(string $interface): bool
{
try {
$finder = $this->getProactiveFinder();
$initializerInfo = $finder->findInitializerForInterface($interface);
if ($initializerInfo === null) {
return false;
}
// Registriere Initializer als lazy binding
$initializerClass = $initializerInfo->initializerClass->getFullyQualified();
$methodName = $initializerInfo->methodName->toString();
$this->singleton($interface, function (Container $container) use ($initializerClass, $methodName) {
$instance = $container->get($initializerClass);
return $container->invoker->invoke($initializerClass, $methodName);
});
// Gefundenen Initializer in Discovery Cache nachtragen
$this->updateDiscoveryCache($initializerInfo);
return true;
} catch (\Throwable $e) {
// Silently ignore errors during proactive search
return false;
}
}
/**
* Aktualisiert den Discovery Cache mit einem proaktiv gefundenen Initializer
*/
private function updateDiscoveryCache(InitializerInfo $initializerInfo): void
{
try {
$updater = $this->getCacheUpdater();
if ($updater === null) {
return;
}
// Versuche zuerst Registry aus Container zu laden
$registry = $this->has(DiscoveryRegistry::class)
? $this->get(DiscoveryRegistry::class)
: null;
$updater->updateCache($initializerInfo, $registry);
} catch (\Throwable $e) {
// Silently ignore errors during cache update
// Initializer funktioniert trotzdem, nur Cache-Update schlägt fehl
}
}
/**
* Holt oder erstellt einen InitializerCacheUpdater
*/
private function getCacheUpdater(): ?InitializerCacheUpdater
{
try {
// Prüfe ob alle benötigten Abhängigkeiten verfügbar sind
if (!$this->has(\App\Framework\Discovery\Storage\DiscoveryCacheManager::class)) {
// Versuche DiscoveryCacheManager zu erstellen
if (!$this->has(\App\Framework\Cache\Cache::class) ||
!$this->has(\App\Framework\DateTime\Clock::class) ||
!$this->has(\App\Framework\Filesystem\FileSystemService::class)) {
return null;
}
$cache = $this->get(\App\Framework\Cache\Cache::class);
$clock = $this->get(\App\Framework\DateTime\Clock::class);
$fileSystemService = $this->get(\App\Framework\Filesystem\FileSystemService::class);
$cacheManager = new \App\Framework\Discovery\Storage\DiscoveryCacheManager(
cache: $cache,
clock: $clock,
fileSystemService: $fileSystemService
);
} else {
$cacheManager = $this->get(\App\Framework\Discovery\Storage\DiscoveryCacheManager::class);
}
$pathProvider = $this->has(\App\Framework\Core\PathProvider::class)
? $this->get(\App\Framework\Core\PathProvider::class)
: new \App\Framework\Core\PathProvider(getcwd() ?: '.');
$clock = $this->has(\App\Framework\DateTime\Clock::class)
? $this->get(\App\Framework\DateTime\Clock::class)
: new \App\Framework\DateTime\SystemClock();
return new InitializerCacheUpdater(
reflectionProvider: $this->reflectionProvider,
cacheManager: $cacheManager,
pathProvider: $pathProvider,
clock: $clock
);
} catch (\Throwable $e) {
return null;
}
}
/**
* Extrahiert den Return-Type aus einem DiscoveredAttribute
*/
private function getInitializerReturnType(DiscoveredAttribute $initializer): ?string
{
return $initializer->additionalData['return'] ?? null;
}
}

View File

@@ -131,7 +131,19 @@ final class ClassResolutionException extends ContainerException
$message .= " - Initializer: {$this->matchingInitializer}\n";
$message .= " - Return Type: {$class}\n";
$errorMessage = $previous !== null ? $previous->getMessage() : $reason;
$message .= " - Error: {$errorMessage}\n";
// Für ParseError: Zeige Datei und Zeile für besseres Debugging
if ($previous instanceof \ParseError) {
$file = $previous->getFile();
$line = $previous->getLine();
// Relativer Pfad für bessere Lesbarkeit
$relativeFile = str_replace(getcwd() . '/', '', $file);
$message .= " - Error: {$errorMessage}\n";
$message .= " - File: {$relativeFile}:{$line}\n";
} else {
$message .= " - Error: {$errorMessage}\n";
}
$message .= " - Exception: " . ($previous !== null ? get_class($previous) : 'Unknown') . "\n";
}

View File

@@ -13,16 +13,27 @@ final class InitializerCycleException extends FrameworkException
{
/**
* @param array<array<string>> $cycles Array von Cycles, jeder Cycle ist ein Array von Return-Types
* @param array<array<string>> $dependencyPaths Vollständige Dependency-Pfade für jeden Cycle (optional)
*/
public function __construct(
private readonly array $cycles,
private readonly array $dependencyPaths = [],
int $code = 0,
?\Throwable $previous = null
) {
$messages = array_map(
fn(array $cycle): string => implode(' → ', $cycle) . ' → ' . $cycle[0],
$cycles
);
$messages = [];
foreach ($cycles as $index => $cycle) {
$cycleMessage = implode(' → ', $cycle) . ' → ' . $cycle[0];
// Füge Dependency-Pfad hinzu wenn verfügbar
if (isset($this->dependencyPaths[$index]) && !empty($this->dependencyPaths[$index])) {
$path = $this->dependencyPaths[$index];
$pathMessage = implode(' → ', $path);
$messages[] = $cycleMessage . PHP_EOL . ' Dependency path: ' . $pathMessage;
} else {
$messages[] = $cycleMessage;
}
}
$message = 'Circular dependencies detected in initializers:' . PHP_EOL
. implode(PHP_EOL, array_map(fn(string $m): string => ' - ' . $m, $messages));
@@ -43,6 +54,15 @@ final class InitializerCycleException extends FrameworkException
return $this->cycles;
}
/**
* Gibt die vollständigen Dependency-Pfade zurück
* @return array<array<string>>
*/
public function getDependencyPaths(): array
{
return $this->dependencyPaths;
}
/**
* Gibt die Anzahl der gefundenen Cycles zurück
*/

View File

@@ -12,9 +12,30 @@ final readonly class Initializer
/** @var ContextType[]|null */
public ?array $contexts;
public function __construct(ContextType ...$contexts)
{
$this->contexts = empty($contexts) ? null : $contexts;
/** @var string[]|null Explizit deklarierte Dependencies */
public ?array $dependencies;
/** @var int Priority für Ausführungsreihenfolge (höher = früher) */
public int $priority;
/**
* @param ContextType|ContextType[]|null $contexts Context-Einschränkungen
* @param string[]|null $dependencies Explizit deklarierte Dependencies
* @param int $priority Priority für Ausführungsreihenfolge (höher = früher, Standard: 0)
*/
public function __construct(
ContextType|array|null $contexts = null,
?array $dependencies = null,
int $priority = 0
) {
// Unterstütze sowohl einzelne ContextType als auch Array für contexts (Rückwärtskompatibilität)
if ($contexts instanceof ContextType) {
$contexts = [$contexts];
}
$this->contexts = empty($contexts) ? null : (is_array($contexts) ? $contexts : [$contexts]);
$this->dependencies = $dependencies;
$this->priority = $priority;
}
public function allowsContext(ContextType $context): bool

View File

@@ -88,7 +88,7 @@ final readonly class InitializerDependencyAnalyzer
$constructorDeps[] = $typeName;
// Prüfe ob Container verfügbar ist
if ($typeName === Container::class || $typeName === 'App\Framework\DI\Container') {
if (self::isContainerClass($typeName)) {
$hasContainerInConstructor = true;
}
}
@@ -108,7 +108,7 @@ final readonly class InitializerDependencyAnalyzer
$type = $parameter->getType();
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
$typeName = $type->getName();
if ($typeName === Container::class || $typeName === 'App\Framework\DI\Container') {
if (self::isContainerClass($typeName)) {
$hasContainerInInvoke = true;
}
@@ -153,6 +153,17 @@ final readonly class InitializerDependencyAnalyzer
}
}
/**
* Prüft ob eine Klasse die Container-Klasse ist
*
* @param string $class Die zu prüfende Klasse
* @return bool True wenn die Klasse Container ist
*/
public static function isContainerClass(string $class): bool
{
return $class === Container::class || $class === 'App\Framework\DI\Container';
}
/**
* Finde rekursiv den Pfad von einer Dependency bis zum Interface
*

View File

@@ -31,23 +31,35 @@ final class InitializerDependencyGraph
public function __construct(
private readonly ReflectionService $reflectionService,
private readonly ?InitializerDependencyAnalyzer $dependencyAnalyzer = null,
) {
}
public function addInitializer(string $returnType, ClassName $className, MethodName $methodName): void
{
/**
* @param string[]|null $explicitDependencies Explizit deklarierte Dependencies (optional)
* @param int $priority Priority für Ausführungsreihenfolge (höher = früher)
*/
public function addInitializer(
string $returnType,
ClassName $className,
MethodName $methodName,
?array $explicitDependencies = null,
int $priority = 0
): void {
// Setup-Initializer werden sofort ausgeführt, nicht zum Graph hinzugefügt
if (ReturnType::hasNoReturnString($returnType)) {
return;
}
$dependencies = $this->analyzeDependencies($className, $methodName);
// Nutze explizite Dependencies wenn vorhanden, sonst Code-Analyse
$dependencies = $explicitDependencies ?? $this->analyzeDependencies($className, $methodName);
$node = new DependencyGraphNode(
returnType: $returnType,
className: $className,
methodName: $methodName,
dependencies: $dependencies
dependencies: $dependencies,
priority: $priority
);
$this->nodes[$returnType] = $node;
@@ -62,12 +74,15 @@ final class InitializerDependencyGraph
public function getExecutionOrder(): array
{
// Proaktive Cycle-Detection: Prüfe alle Nodes bevor wir sortieren
$cycles = $this->detectAllCycles();
$cycleResult = $this->detectAllCycles();
$cycles = $cycleResult['cycles'];
$paths = $cycleResult['paths'];
if (! empty($cycles)) {
throw new InitializerCycleException($cycles);
throw new InitializerCycleException($cycles, $paths);
}
// Topologische Sortierung (ursprüngliche Logik)
// Topologische Sortierung mit Priority-Berücksichtigung
$this->visited = [];
$this->inStack = [];
$result = [];
@@ -78,7 +93,11 @@ final class InitializerDependencyGraph
}
}
return array_reverse($result);
$sorted = array_reverse($result);
// Sortiere nach Priority bei gleicher Dependency-Ebene
// Höhere Priority = früher in der Ausführungsreihenfolge
return $this->sortByPriority($sorted);
}
/**
@@ -119,10 +138,37 @@ final class InitializerDependencyGraph
}
/**
* Analysiert die Dependencies eines Initializers durch Reflection
* Analysiert die Dependencies eines Initializers
*
* Nutzt InitializerDependencyAnalyzer wenn verfügbar für vollständige Analyse
* (Constructor-Dependencies + container->get() Aufrufe), sonst Fallback zu Reflection.
*/
private function analyzeDependencies(ClassName $className, MethodName $methodName): array
{
// Nutze InitializerDependencyAnalyzer wenn verfügbar für vollständige Dependency-Analyse
if ($this->dependencyAnalyzer !== null) {
try {
$analysis = $this->dependencyAnalyzer->analyze($className->getFullyQualified());
// Kombiniere Constructor-Dependencies und container->get() Dependencies
$allDependencies = array_unique(array_merge(
$analysis['constructorDeps'],
$analysis['containerGetDeps']
));
// Filtere Container selbst heraus (ist keine echte Dependency für den Graph)
$allDependencies = array_filter(
$allDependencies,
fn(string $dep) => !\App\Framework\DI\InitializerDependencyAnalyzer::isContainerClass($dep)
);
return array_values($allDependencies);
} catch (\Throwable $e) {
// Fallback zu Reflection-basierter Analyse
}
}
// Fallback: Reflection-basierte Analyse (nur Method-Parameter)
try {
$parameters = $this->reflectionService->getMethodParameters($className, $methodName->toString());
$dependencies = [];
@@ -133,6 +179,12 @@ final class InitializerDependencyGraph
// Nur Klassen/Interfaces als Dependencies, keine primitiven Typen
if ($paramType !== null && ! $paramType->isBuiltin()) {
$typeName = $paramType->getName();
// Filtere Container selbst heraus
if (\App\Framework\DI\InitializerDependencyAnalyzer::isContainerClass($typeName)) {
continue;
}
if (ClassName::create($typeName)->exists()) {
$dependencies[] = $typeName;
}
@@ -172,17 +224,21 @@ final class InitializerDependencyGraph
/**
* Findet alle Cycles im Dependency Graph
* @return array<array<string>> Array von Cycles, jeder Cycle ist ein Array von Return-Types
* @return array{cycles: array<array<string>>, paths: array<array<string>>} Array von Cycles und Dependency-Pfaden
*/
private function detectAllCycles(): array
{
$cycles = [];
$paths = [];
$visited = [];
$inStack = [];
foreach (array_keys($this->nodes) as $returnType) {
if (! isset($visited[$returnType])) {
$cycle = $this->detectCycle($returnType, $visited, $inStack);
$result = $this->detectCycle($returnType, $visited, $inStack);
$cycle = $result['cycle'] ?? [];
$path = $result['path'] ?? [];
if (! empty($cycle)) {
// Prüfe ob dieser Cycle bereits gefunden wurde (als Teil eines anderen Cycles)
$isDuplicate = false;
@@ -194,12 +250,13 @@ final class InitializerDependencyGraph
}
if (! $isDuplicate) {
$cycles[] = $cycle;
$paths[] = $path;
}
}
}
}
return $cycles;
return ['cycles' => $cycles, 'paths' => $paths];
}
/**
@@ -207,7 +264,7 @@ final class InitializerDependencyGraph
* @param array<string, bool> $visited Referenz auf visited Array
* @param array<string, bool> $inStack Referenz auf inStack Array
* @param array<string> $path Aktueller Pfad während der Traversierung
* @return array<string> Leer wenn kein Cycle, sonst Array mit Cycle-Pfad
* @return array{cycle: array<string>, path: array<string>} Cycle und vollständiger Dependency-Pfad
*/
private function detectCycle(string $returnType, array &$visited, array &$inStack, array $path = []): array
{
@@ -217,12 +274,16 @@ final class InitializerDependencyGraph
if ($cycleStart !== false) {
$cycle = array_slice($path, $cycleStart);
$cycle[] = $returnType; // Schließe den Cycle
return $cycle;
// Erstelle vollständigen Dependency-Pfad durch Nutzung von InitializerDependencyAnalyzer
$fullPath = $this->buildDependencyPath($cycle);
return ['cycle' => $cycle, 'path' => $fullPath];
}
}
if (isset($visited[$returnType])) {
return [];
return ['cycle' => [], 'path' => []];
}
$visited[$returnType] = true;
@@ -231,9 +292,9 @@ final class InitializerDependencyGraph
foreach ($this->adjacencyList[$returnType] ?? [] as $dependency) {
if (isset($this->nodes[$dependency])) {
$cycle = $this->detectCycle($dependency, $visited, $inStack, $path);
if (! empty($cycle)) {
return $cycle;
$result = $this->detectCycle($dependency, $visited, $inStack, $path);
if (! empty($result['cycle'])) {
return $result;
}
}
}
@@ -241,6 +302,118 @@ final class InitializerDependencyGraph
unset($inStack[$returnType]);
array_pop($path);
return [];
return ['cycle' => [], 'path' => []];
}
/**
* Baut einen vollständigen Dependency-Pfad für einen Cycle
*
* Nutzt InitializerDependencyAnalyzer wenn verfügbar um vollständige Pfade zu erstellen.
*
* @param array<string> $cycle Der gefundene Cycle
* @return array<string> Vollständiger Dependency-Pfad
*/
private function buildDependencyPath(array $cycle): array
{
// Early return: Wenn Cycle leer oder Analyzer nicht verfügbar, nutze Cycle als Pfad
if (empty($cycle) || $this->dependencyAnalyzer === null) {
return $cycle;
}
try {
$fullPath = [];
$cycleLength = count($cycle);
// Baue Pfad durch jeden Node im Cycle
for ($i = 0; $i < $cycleLength; $i++) {
$current = $cycle[$i];
$next = $cycle[($i + 1) % $cycleLength]; // Nächster im Cycle (mit Wrap-around)
$fullPath[] = $current;
// Versuche vollständigen Pfad zwischen current und next zu finden
$node = $this->nodes[$current] ?? null;
if ($node === null) {
continue;
}
$path = $this->dependencyAnalyzer->findDependencyPathToInterface(
$current,
$next,
[], // visited
[], // currentPath
0 // depth
);
// Füge Zwischen-Pfad hinzu wenn gefunden (ohne Start und Ende, die schon im Cycle sind)
if ($path !== null && count($path) > 2) {
$intermediatePath = array_slice($path, 1, -1);
$fullPath = array_merge($fullPath, $intermediatePath);
}
}
return array_unique($fullPath);
} catch (\Throwable $e) {
// Fallback: Nutze Cycle als Pfad bei Fehlern
return $cycle;
}
}
/**
* Sortiert die Ausführungsreihenfolge nach Priority
*
* Bei gleicher Dependency-Ebene werden Initializer mit höherer Priority zuerst ausgeführt.
* Die Dependency-Reihenfolge bleibt erhalten, nur innerhalb derselben Ebene wird nach Priority sortiert.
*
* @param array<string> $executionOrder Topologisch sortierte Liste
* @return array<string> Nach Priority sortierte Liste
*/
private function sortByPriority(array $executionOrder): array
{
// Gruppiere nach Dependency-Tiefe (Anzahl der Dependencies)
$byDepth = [];
foreach ($executionOrder as $returnType) {
$node = $this->nodes[$returnType] ?? null;
if ($node === null) {
continue;
}
$depth = count($node->dependencies);
if (!isset($byDepth[$depth])) {
$byDepth[$depth] = [];
}
$byDepth[$depth][] = $returnType;
}
// Sortiere innerhalb jeder Tiefe nach Priority (höher = früher)
$sorted = [];
ksort($byDepth); // Sortiere nach Tiefe (0 Dependencies zuerst)
foreach ($byDepth as $depth => $types) {
// Sortiere nach Priority (höher = früher), dann nach ursprünglicher Reihenfolge
usort($types, function (string $a, string $b) use ($executionOrder) {
$nodeA = $this->nodes[$a] ?? null;
$nodeB = $this->nodes[$b] ?? null;
if ($nodeA === null || $nodeB === null) {
return 0;
}
// Höhere Priority = früher (negativer Wert)
$priorityDiff = $nodeB->priority - $nodeA->priority;
if ($priorityDiff !== 0) {
return $priorityDiff;
}
// Bei gleicher Priority: Behalte ursprüngliche Reihenfolge
$indexA = array_search($a, $executionOrder, true);
$indexB = array_search($b, $executionOrder, true);
return ($indexA !== false && $indexB !== false) ? $indexA - $indexB : 0;
});
$sorted = array_merge($sorted, $types);
}
return $sorted;
}
}

View File

@@ -24,6 +24,8 @@ final readonly class InitializerMapper implements AttributeMapper
'class' => $reflectionTarget->getDeclaringClass()->getFullyQualified(),
'return' => $this->getReturnTypeName($reflectionTarget),
'method' => $reflectionTarget->getName(),
'dependencies' => $attributeInstance->dependencies,
'priority' => $attributeInstance->priority,
];
}
@@ -33,6 +35,8 @@ final readonly class InitializerMapper implements AttributeMapper
'class' => $reflectionTarget->getName()->getFullyQualified(),
'return' => null,
'method' => null,
'dependencies' => $attributeInstance->dependencies,
'priority' => $attributeInstance->priority,
];
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI;
use App\Framework\DI\ValueObjects\InitializerInfo;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\ReflectionLegacy\ReflectionProvider;
/**
* Handles proactive initializer discovery and cache updates for interfaces.
*/
final class InterfaceInitializerResolver
{
public function __construct(
private Container $container,
private ReflectionProvider $reflectionProvider,
private ProactiveInitializerFinderFactory $finderFactory,
) {
}
public function tryRegister(string $interface): bool
{
try {
$finder = $this->finderFactory->create();
$initializerInfo = $finder->findInitializerForInterface($interface);
if ($initializerInfo === null) {
return false;
}
$initializerClass = $initializerInfo->initializerClass->getFullyQualified();
$methodName = $initializerInfo->methodName->toString();
$this->container->singleton(
$interface,
function (Container $container) use ($initializerClass, $methodName) {
$instance = $container->get($initializerClass);
return $container->invoker->invoke($initializerClass, $methodName);
}
);
$this->updateDiscoveryCache($initializerInfo);
return true;
} catch (\Throwable $e) {
return false;
}
}
public function getInitializerReturnType(DiscoveredAttribute $initializer): ?string
{
return $initializer->additionalData['return'] ?? null;
}
private function updateDiscoveryCache(InitializerInfo $initializerInfo): void
{
try {
$updater = $this->getCacheUpdater();
if ($updater === null) {
return;
}
$registry = $this->container->has(DiscoveryRegistry::class)
? $this->container->get(DiscoveryRegistry::class)
: null;
$updater->updateCache($initializerInfo, $registry);
} catch (\Throwable $e) {
// Silently ignore cache update errors
}
}
private function getCacheUpdater(): ?InitializerCacheUpdater
{
try {
$cacheManager = $this->container->has(\App\Framework\Discovery\Storage\DiscoveryCacheManager::class)
? $this->container->get(\App\Framework\Discovery\Storage\DiscoveryCacheManager::class)
: $this->buildCacheManager();
if ($cacheManager === null) {
return null;
}
$pathProvider = $this->container->has(\App\Framework\Core\PathProvider::class)
? $this->container->get(\App\Framework\Core\PathProvider::class)
: new \App\Framework\Core\PathProvider(getcwd() ?: '.');
$clock = $this->container->has(\App\Framework\DateTime\Clock::class)
? $this->container->get(\App\Framework\DateTime\Clock::class)
: new \App\Framework\DateTime\SystemClock();
return new InitializerCacheUpdater(
reflectionProvider: $this->reflectionProvider,
cacheManager: $cacheManager,
pathProvider: $pathProvider,
clock: $clock
);
} catch (\Throwable $e) {
return null;
}
}
private function buildCacheManager(): ?\App\Framework\Discovery\Storage\DiscoveryCacheManager
{
if (!$this->container->has(\App\Framework\Cache\Cache::class) ||
!$this->container->has(\App\Framework\DateTime\Clock::class) ||
!$this->container->has(\App\Framework\Filesystem\FileSystemService::class)) {
return null;
}
return new \App\Framework\Discovery\Storage\DiscoveryCacheManager(
cache: $this->container->get(\App\Framework\Cache\Cache::class),
clock: $this->container->get(\App\Framework\DateTime\Clock::class),
fileSystemService: $this->container->get(\App\Framework\Filesystem\FileSystemService::class)
);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI;
use App\Framework\Core\PathProvider;
use App\Framework\Discovery\Processing\ClassExtractor;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\FileSystemService;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\ReflectionProvider;
/**
* Factory für ProactiveInitializerFinder
*
* Zentralisiert die Erstellung von ProactiveInitializerFinder und entfernt Code-Duplikation
* zwischen DefaultContainer und InitializerProcessor.
*/
final readonly class ProactiveInitializerFinderFactory
{
public function __construct(
private Container $container,
private ?ReflectionProvider $reflectionProvider = null
) {
}
/**
* Erstellt einen ProactiveInitializerFinder mit allen notwendigen Dependencies
*/
public function create(): ProactiveInitializerFinder
{
// Versuche DiscoveryRegistry aus Container zu holen
$discoveryRegistry = $this->container->has(DiscoveryRegistry::class)
? $this->container->get(DiscoveryRegistry::class)
: DiscoveryRegistry::empty();
// Versuche FileScanner aus Container zu holen
$fileScanner = $this->container->has(FileScanner::class)
? $this->container->get(FileScanner::class)
: new FileScanner(null, null, new FileSystemService());
// Erstelle ClassExtractor
$fileSystemService = new FileSystemService();
$classExtractor = new ClassExtractor($fileSystemService);
// Versuche PathProvider aus Container zu holen
$pathProvider = $this->container->has(PathProvider::class)
? $this->container->get(PathProvider::class)
: new PathProvider(getcwd() ?: '.');
// Nutze ReflectionProvider aus Constructor oder erstelle neuen
$reflectionProvider = $this->reflectionProvider ?? new CachedReflectionProvider();
return new ProactiveInitializerFinder(
reflectionProvider: $reflectionProvider,
discoveryRegistry: $discoveryRegistry,
fileScanner: $fileScanner,
classExtractor: $classExtractor,
pathProvider: $pathProvider
);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\DI\Exceptions\CyclicDependencyException;
use App\Framework\Metrics\FrameworkMetricsCollector;
use Throwable;
/**
* Coordinates instance creation and tracks the current resolution stack.
*/
final class ResolutionPipeline
{
/** @var array<class-string> */
private array $resolving = [];
public function __construct(
private InstanceRegistry $instances,
private SingletonDetector $singletonDetector,
private FrameworkMetricsCollector $metrics,
private InitializerDependencyAnalyzer $dependencyAnalyzer,
) {
}
/**
* @param callable(string):object $builder
*/
public function resolve(string $class, callable $builder): object
{
$this->enter($class);
try {
$instance = $builder($class);
$this->storeInstance($class, $instance);
return $instance;
} catch (Throwable $e) {
$this->metrics->increment('container.resolve.failures');
throw $e;
} finally {
array_pop($this->resolving);
}
}
/**
* @return array<class-string>
*/
public function getResolvingStack(): array
{
return $this->resolving;
}
private function enter(string $class): void
{
if (in_array($class, $this->resolving, true)) {
throw new CyclicDependencyException(
dependencyChain: $this->resolving,
class: $class,
code: 0,
previous: null,
dependencyAnalyzer: $this->dependencyAnalyzer
);
}
$this->resolving[] = $class;
}
private function storeInstance(string $class, object $instance): void
{
if ((class_exists($class) || interface_exists($class)) &&
$this->singletonDetector->isSingleton(ClassName::create($class))) {
$this->instances->setSingleton($class, $instance);
return;
}
$this->instances->setInstance($class, $instance);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI\ValueObjects;
/**
* Represents the result of cycle detection in a dependency graph
*/
final readonly class CycleDetectionResult
{
/**
* @param array<string> $cycle The detected cycle (array of return types)
* @param array<string> $path The full dependency path for the cycle
*/
public function __construct(
public array $cycle,
public array $path
) {
}
/**
* Check if a cycle was detected
*/
public function hasCycle(): bool
{
return !empty($this->cycle);
}
/**
* Convert to array for JSON output
*
* @return array{cycle: array<string>, path: array<string>}
*/
public function toArray(): array
{
return [
'cycle' => $this->cycle,
'path' => $this->path,
];
}
}

View File

@@ -20,12 +20,14 @@ final readonly class DependencyGraphNode
* @param ClassName $className The class containing the initializer method
* @param MethodName $methodName The method name to invoke
* @param string[] $dependencies Array of return types this node depends on
* @param int $priority Priority for execution order (higher = earlier, default: 0)
*/
public function __construct(
public string $returnType,
public ClassName $className,
public MethodName $methodName,
public array $dependencies = []
public array $dependencies = [],
public int $priority = 0
) {
}
@@ -33,7 +35,7 @@ final readonly class DependencyGraphNode
* Create a node from legacy array format
*
* @param string $returnType
* @param array{class: string, method: string, dependencies?: string[]} $nodeData
* @param array{class: string, method: string, dependencies?: string[], priority?: int} $nodeData
* @return self
*/
public static function fromArray(string $returnType, array $nodeData): self
@@ -42,14 +44,15 @@ final readonly class DependencyGraphNode
returnType: $returnType,
className: ClassName::create($nodeData['class']),
methodName: MethodName::create($nodeData['method']),
dependencies: $nodeData['dependencies'] ?? []
dependencies: $nodeData['dependencies'] ?? [],
priority: $nodeData['priority'] ?? 0
);
}
/**
* Convert to legacy array format for backward compatibility
*
* @return array{class: string, method: string, dependencies: string[]}
* @return array{class: string, method: string, dependencies: string[], priority: int}
*/
public function toArray(): array
{
@@ -57,6 +60,7 @@ final readonly class DependencyGraphNode
'class' => $this->className->getFullyQualified(),
'method' => $this->methodName->toString(),
'dependencies' => $this->dependencies,
'priority' => $this->priority,
];
}
@@ -87,7 +91,8 @@ final readonly class DependencyGraphNode
returnType: $this->returnType,
className: $this->className,
methodName: $this->methodName,
dependencies: array_unique(array_merge($this->dependencies, $additionalDependencies))
dependencies: array_unique(array_merge($this->dependencies, $additionalDependencies)),
priority: $this->priority
);
}
@@ -122,7 +127,7 @@ final readonly class DependencyGraphNode
/**
* Get debug information
*
* @return array{return_type: string, class: string, method: string, dependencies: string[], has_dependencies: bool}
* @return array{return_type: string, class: string, method: string, dependencies: string[], has_dependencies: bool, priority: int}
*/
public function getDebugInfo(): array
{
@@ -132,6 +137,7 @@ final readonly class DependencyGraphNode
'method' => $this->getMethodName(),
'dependencies' => $this->dependencies,
'has_dependencies' => $this->hasDependencies(),
'priority' => $this->priority,
];
}
}

View File

@@ -4,18 +4,40 @@ declare(strict_types=1);
namespace App\Framework\DI\ValueObjects;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
/**
* Value Object für gefundene Initializer-Informationen
* Represents information about an initializer
*/
final readonly class InitializerInfo
{
/**
* @param string $type Info type (typically 'info')
* @param string $class Initializer class name
* @param string $method Initializer method name
* @param string $returnType Return type of the initializer
* @param array<string> $dependencies List of dependencies
*/
public function __construct(
public ClassName $initializerClass,
public MethodName $methodName,
public ClassName $returnType
) {}
}
public string $type,
public string $class,
public string $method,
public string $returnType,
public array $dependencies
) {
}
/**
* Convert to array for JSON output
*
* @return array{type: string, class: string, method: string, return_type: string, dependencies: array<string>}
*/
public function toArray(): array
{
return [
'type' => $this->type,
'class' => $this->class,
'method' => $this->method,
'return_type' => $this->returnType,
'dependencies' => $this->dependencies,
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI\ValueObjects;
/**
* Represents a problem found during initializer analysis
*/
final readonly class InitializerProblem
{
/**
* @param string $type Problem type (e.g., 'error', 'warning')
* @param string $message Human-readable problem message
* @param string|null $class Initializer class name (if applicable)
* @param string|null $method Initializer method name (if applicable)
* @param array<string>|null $cycle Circular dependency cycle (if applicable)
* @param array<string>|null $dependencyPath Full dependency path (if applicable)
* @param string|null $exception Exception class name (if applicable)
*/
public function __construct(
public string $type,
public string $message,
public ?string $class = null,
public ?string $method = null,
public ?array $cycle = null,
public ?array $dependencyPath = null,
public ?string $exception = null
) {
}
/**
* Convert to array for JSON output
*
* @return array{type: string, message: string, class?: string, method?: string, cycle?: array<string>, dependency_path?: array<string>, exception?: string}
*/
public function toArray(): array
{
$result = [
'type' => $this->type,
'message' => $this->message,
];
if ($this->class !== null) {
$result['class'] = $this->class;
}
if ($this->method !== null) {
$result['method'] = $this->method;
}
if ($this->cycle !== null) {
$result['cycle'] = $this->cycle;
}
if ($this->dependencyPath !== null) {
$result['dependency_path'] = $this->dependencyPath;
}
if ($this->exception !== null) {
$result['exception'] = $this->exception;
}
return $result;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI\ValueObjects;
/**
* Represents a warning found during initializer analysis
*/
final readonly class InitializerWarning
{
/**
* @param string $type Warning type (typically 'warning')
* @param string $message Human-readable warning message
* @param string $class Initializer class name
* @param string $method Initializer method name
* @param string $returnType Return type of the initializer
* @param array<string> $missingDependencies List of potentially missing dependencies
*/
public function __construct(
public string $type,
public string $message,
public string $class,
public string $method,
public string $returnType,
public array $missingDependencies
) {
}
/**
* Convert to array for JSON output
*
* @return array{type: string, message: string, class: string, method: string, return_type: string, missing_dependencies: array<string>}
*/
public function toArray(): array
{
return [
'type' => $this->type,
'message' => $this->message,
'class' => $this->class,
'method' => $this->method,
'return_type' => $this->returnType,
'missing_dependencies' => $this->missingDependencies,
];
}
}

View File

@@ -5,15 +5,20 @@ declare(strict_types=1);
namespace App\Framework\Database\Browser\Discovery;
use App\Framework\Database\Browser\ValueObjects\DatabaseMetadata;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class DatabaseDiscovery
{
public function __construct(
private ConnectionInterface $connection
private DatabaseManager $databaseManager
) {
}
private function getConnection(): \App\Framework\Database\ConnectionInterface
{
return $this->databaseManager->getConnection();
}
/**
* Discover current database metadata
@@ -21,7 +26,70 @@ final readonly class DatabaseDiscovery
public function discoverCurrentDatabase(): DatabaseMetadata
{
$databaseName = $this->getCurrentDatabase();
$driver = $this->getDriver();
if ($driver === 'pgsql') {
return $this->discoverPostgreSQLDatabase($databaseName);
}
return $this->discoverMySQLDatabase($databaseName);
}
/**
* Discover PostgreSQL database metadata
*/
private function discoverPostgreSQLDatabase(string $databaseName): DatabaseMetadata
{
// Get database encoding and collation from pg_database
$sql = "
SELECT
encoding,
datcollate as collation
FROM pg_database
WHERE datname = ?
";
$query = SqlQuery::create($sql, [$databaseName]);
$result = $this->getConnection()->query($query);
$row = $result->fetch();
// Get encoding name
$charset = null;
if (isset($row['encoding'])) {
$encodingMap = [
6 => 'UTF8',
0 => 'SQL_ASCII',
];
$charset = $encodingMap[(int) $row['encoding']] ?? 'UTF8';
}
// Get table count and size
$tableSql = "
SELECT
COUNT(*) as table_count,
COALESCE(ROUND(SUM(pg_total_relation_size(schemaname||'.'||tablename)) / 1024.0 / 1024.0, 2), 0) as size_mb
FROM pg_tables
WHERE schemaname = 'public'
";
$tableQuery = SqlQuery::create($tableSql, []);
$tableResult = $this->getConnection()->query($tableQuery);
$tableRow = $tableResult->fetch();
return new DatabaseMetadata(
name: $databaseName,
charset: $charset,
collation: $row['collation'] ?? null,
tableCount: isset($tableRow['table_count']) ? (int) $tableRow['table_count'] : null,
sizeMb: isset($tableRow['size_mb']) ? (float) $tableRow['size_mb'] : null,
);
}
/**
* Discover MySQL database metadata
*/
private function discoverMySQLDatabase(string $databaseName): DatabaseMetadata
{
// Get database metadata
$sql = "
SELECT
@@ -32,7 +100,7 @@ final readonly class DatabaseDiscovery
";
$query = SqlQuery::create($sql, [$databaseName]);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$row = $result->fetch();
// Get table count and size
@@ -62,12 +130,28 @@ final readonly class DatabaseDiscovery
*/
public function getCurrentDatabase(): string
{
$sql = "SELECT DATABASE() as db";
$driver = $this->getDriver();
if ($driver === 'pgsql') {
$sql = "SELECT current_database() as db";
} else {
$sql = "SELECT DATABASE() as db";
}
$query = SqlQuery::create($sql, []);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$row = $result->fetch();
return $row['db'] ?? 'unknown';
}
/**
* Get database driver name
*/
private function getDriver(): string
{
$pdo = $this->getConnection()->getPdo();
return $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
}
}

View File

@@ -8,20 +8,39 @@ use App\Framework\Database\Browser\ValueObjects\ColumnMetadata;
use App\Framework\Database\Browser\ValueObjects\ForeignKeyMetadata;
use App\Framework\Database\Browser\ValueObjects\IndexMetadata;
use App\Framework\Database\Browser\ValueObjects\TableMetadata;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class SchemaDiscovery
{
public function __construct(
private ConnectionInterface $connection
private DatabaseManager $databaseManager
) {
}
private function getConnection(): \App\Framework\Database\ConnectionInterface
{
return $this->databaseManager->getConnection();
}
/**
* Discover complete schema for a table (columns, indexes, foreign keys)
*/
public function discoverTableSchema(string $tableName): ?TableMetadata
{
$driver = $this->getDriver();
if ($driver === 'pgsql') {
return $this->discoverPostgreSQLTableSchema($tableName);
}
return $this->discoverMySQLTableSchema($tableName);
}
/**
* Discover MySQL table schema
*/
private function discoverMySQLTableSchema(string $tableName): ?TableMetadata
{
$databaseName = $this->getCurrentDatabase();
@@ -39,7 +58,7 @@ final readonly class SchemaDiscovery
";
$tableQuery = SqlQuery::create($tableSql, [$databaseName, $tableName]);
$tableResult = $this->connection->query($tableQuery);
$tableResult = $this->getConnection()->query($tableQuery);
$tableRow = $tableResult->fetch();
if ($tableRow === null) {
@@ -67,6 +86,67 @@ final readonly class SchemaDiscovery
);
}
/**
* Discover PostgreSQL table schema
*/
private function discoverPostgreSQLTableSchema(string $tableName): ?TableMetadata
{
// Check if table exists
$checkSql = "
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename = ?
";
$checkQuery = SqlQuery::create($checkSql, [$tableName]);
$checkResult = $this->getConnection()->query($checkQuery);
$checkRow = $checkResult->fetch();
if ($checkRow === null) {
return null;
}
// Get basic table info
$tableSql = "
SELECT
t.tablename as table_name,
COALESCE(s.n_live_tup::bigint, 0) as table_rows,
ROUND(pg_total_relation_size('public.'||?) / 1024.0 / 1024.0, 2) as size_mb
FROM pg_tables t
LEFT JOIN pg_stat_user_tables s ON s.relname = t.tablename
WHERE t.schemaname = 'public'
AND t.tablename = ?
";
$tableQuery = SqlQuery::create($tableSql, [$tableName, $tableName]);
$tableResult = $this->getConnection()->query($tableQuery);
$tableRow = $tableResult->fetch();
if ($tableRow === null) {
return null;
}
// Get columns (PostgreSQL uses information_schema.columns)
$columns = $this->discoverPostgreSQLColumns($tableName);
// Get indexes
$indexes = $this->discoverPostgreSQLIndexes($tableName);
// Get foreign keys
$foreignKeys = $this->discoverPostgreSQLForeignKeys($tableName);
return new TableMetadata(
name: $tableRow['table_name'],
rowCount: isset($tableRow['table_rows']) && $tableRow['table_rows'] !== null ? (int) $tableRow['table_rows'] : null,
sizeMb: isset($tableRow['size_mb']) ? (float) $tableRow['size_mb'] : null,
engine: null, // PostgreSQL doesn't have engine concept
collation: null, // PostgreSQL collation is per-column, not per-table
columns: $columns,
indexes: $indexes,
foreignKeys: $foreignKeys,
);
}
/**
* Discover columns for a table
*
@@ -74,6 +154,12 @@ final readonly class SchemaDiscovery
*/
public function discoverColumns(string $tableName, ?string $databaseName = null): array
{
$driver = $this->getDriver();
if ($driver === 'pgsql') {
return $this->discoverPostgreSQLColumns($tableName);
}
$databaseName ??= $this->getCurrentDatabase();
$sql = "
@@ -92,7 +178,54 @@ final readonly class SchemaDiscovery
";
$query = SqlQuery::create($sql, [$databaseName, $tableName]);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$rows = $result->fetchAll();
$columns = [];
foreach ($rows as $row) {
$columns[] = ColumnMetadata::fromArray($row);
}
return $columns;
}
/**
* Discover PostgreSQL columns for a table
*
* @return array<ColumnMetadata>
*/
private function discoverPostgreSQLColumns(string $tableName): array
{
$sql = "
SELECT
c.column_name,
c.udt_name as column_type,
c.is_nullable,
c.column_default,
CASE
WHEN pk.column_name IS NOT NULL THEN 'PRI'
ELSE ''
END as column_key,
'' as extra,
c.ordinal_position
FROM information_schema.columns c
LEFT JOIN (
SELECT ku.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage ku
ON tc.constraint_name = ku.constraint_name
AND tc.table_schema = ku.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = 'public'
AND tc.table_name = ?
) pk ON c.column_name = pk.column_name
WHERE c.table_schema = 'public'
AND c.table_name = ?
ORDER BY c.ordinal_position
";
$query = SqlQuery::create($sql, [$tableName, $tableName]);
$result = $this->getConnection()->query($query);
$rows = $result->fetchAll();
$columns = [];
@@ -110,6 +243,12 @@ final readonly class SchemaDiscovery
*/
public function discoverIndexes(string $tableName, ?string $databaseName = null): array
{
$driver = $this->getDriver();
if ($driver === 'pgsql') {
return $this->discoverPostgreSQLIndexes($tableName);
}
$databaseName ??= $this->getCurrentDatabase();
$sql = "
@@ -125,7 +264,7 @@ final readonly class SchemaDiscovery
";
$query = SqlQuery::create($sql, [$databaseName, $tableName]);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$rows = $result->fetchAll();
// Group indexes by name
@@ -151,6 +290,57 @@ final readonly class SchemaDiscovery
return $indexes;
}
/**
* Discover PostgreSQL indexes for a table
*
* @return array<IndexMetadata>
*/
private function discoverPostgreSQLIndexes(string $tableName): array
{
$sql = "
SELECT
i.relname as index_name,
a.attname as column_name,
CASE WHEN ix.indisunique THEN 0 ELSE 1 END as non_unique,
am.amname as index_type
FROM pg_class t
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_am am ON i.relam = am.oid
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
WHERE t.relkind = 'r'
AND t.relname = ?
AND pg_get_indexdef(ix.indexrelid) NOT LIKE '%UNIQUE%'
ORDER BY i.relname, array_position(ix.indkey, a.attnum)
";
$query = SqlQuery::create($sql, [$tableName]);
$result = $this->getConnection()->query($query);
$rows = $result->fetchAll();
// Group indexes by name
$grouped = [];
foreach ($rows as $row) {
$name = $row['index_name'];
if (!isset($grouped[$name])) {
$grouped[$name] = [
'index_name' => $name,
'non_unique' => $row['non_unique'],
'index_type' => $row['index_type'] ?? 'btree',
'columns' => [],
];
}
$grouped[$name]['columns'][] = $row['column_name'];
}
$indexes = [];
foreach ($grouped as $indexData) {
$indexes[] = IndexMetadata::fromArray($indexData);
}
return $indexes;
}
/**
* Discover foreign keys for a table
*
@@ -158,6 +348,12 @@ final readonly class SchemaDiscovery
*/
public function discoverForeignKeys(string $tableName, ?string $databaseName = null): array
{
$driver = $this->getDriver();
if ($driver === 'pgsql') {
return $this->discoverPostgreSQLForeignKeys($tableName);
}
$databaseName ??= $this->getCurrentDatabase();
$sql = "
@@ -174,7 +370,45 @@ final readonly class SchemaDiscovery
";
$query = SqlQuery::create($sql, [$databaseName, $tableName]);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$rows = $result->fetchAll();
$foreignKeys = [];
foreach ($rows as $row) {
$foreignKeys[] = ForeignKeyMetadata::fromArray($row);
}
return $foreignKeys;
}
/**
* Discover PostgreSQL foreign keys for a table
*
* @return array<ForeignKeyMetadata>
*/
private function discoverPostgreSQLForeignKeys(string $tableName): array
{
$sql = "
SELECT
tc.constraint_name,
kcu.column_name,
ccu.table_name AS referenced_table_name,
ccu.column_name AS referenced_column_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND tc.table_name = ?
ORDER BY tc.constraint_name
";
$query = SqlQuery::create($sql, [$tableName]);
$result = $this->getConnection()->query($query);
$rows = $result->fetchAll();
$foreignKeys = [];
@@ -190,12 +424,28 @@ final readonly class SchemaDiscovery
*/
private function getCurrentDatabase(): string
{
$sql = "SELECT DATABASE() as db";
$driver = $this->getDriver();
if ($driver === 'pgsql') {
$sql = "SELECT current_database() as db";
} else {
$sql = "SELECT DATABASE() as db";
}
$query = SqlQuery::create($sql, []);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$row = $result->fetch();
return $row['db'] ?? 'unknown';
}
/**
* Get database driver name
*/
private function getDriver(): string
{
$pdo = $this->getConnection()->getPdo();
return $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
}
}

View File

@@ -5,15 +5,20 @@ declare(strict_types=1);
namespace App\Framework\Database\Browser\Discovery;
use App\Framework\Database\Browser\ValueObjects\TableMetadata;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class TableDiscovery
{
public function __construct(
private ConnectionInterface $connection
private DatabaseManager $databaseManager
) {
}
private function getConnection(): \App\Framework\Database\ConnectionInterface
{
return $this->databaseManager->getConnection();
}
/**
* Discover all tables in the current database
@@ -21,6 +26,72 @@ final readonly class TableDiscovery
* @return array<TableMetadata>
*/
public function discoverAllTables(): array
{
$driver = $this->getDriver();
if ($driver === 'pgsql') {
return $this->discoverPostgreSQLTables();
}
return $this->discoverMySQLTables();
}
/**
* Discover PostgreSQL tables
*/
private function discoverPostgreSQLTables(): array
{
$sql = "
SELECT
t.tablename as table_name,
COALESCE(s.n_live_tup::bigint, 0) as estimated_rows,
ROUND(pg_total_relation_size('public.'||t.tablename) / 1024.0 / 1024.0, 2) as size_mb,
NULL as engine,
NULL as table_collation
FROM pg_tables t
LEFT JOIN pg_stat_user_tables s ON s.relname = t.tablename
WHERE t.schemaname = 'public'
ORDER BY t.tablename
";
$query = SqlQuery::create($sql, []);
$result = $this->getConnection()->query($query);
$tables = $result->fetchAll();
$tableMetadata = [];
foreach ($tables as $tableData) {
$estimatedRows = isset($tableData['estimated_rows']) && $tableData['estimated_rows'] !== null ? (int) $tableData['estimated_rows'] : 0;
// For small tables (< 1000 estimated rows), get exact count
// This ensures accuracy for small tables where pg_stat_user_tables might be outdated
$rowCount = $estimatedRows;
if ($estimatedRows < 1000) {
try {
$countQuery = SqlQuery::create("SELECT COUNT(*) FROM \"{$tableData['table_name']}\"", []);
$exactCount = $this->getConnection()->queryScalar($countQuery);
$rowCount = $exactCount !== null ? (int) $exactCount : $estimatedRows;
} catch (\Throwable $e) {
// If exact count fails, fall back to estimated count
$rowCount = $estimatedRows;
}
}
$tableMetadata[] = new TableMetadata(
name: $tableData['table_name'],
rowCount: $rowCount,
sizeMb: isset($tableData['size_mb']) ? (float) $tableData['size_mb'] : null,
engine: null, // PostgreSQL doesn't have engine concept
collation: null, // PostgreSQL collation is per-column, not per-table
);
}
return $tableMetadata;
}
/**
* Discover MySQL tables
*/
private function discoverMySQLTables(): array
{
$databaseName = $this->getCurrentDatabase();
@@ -38,7 +109,7 @@ final readonly class TableDiscovery
";
$query = SqlQuery::create($sql, [$databaseName]);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$tables = $result->fetchAll();
$tableMetadata = [];
@@ -59,6 +130,55 @@ final readonly class TableDiscovery
* Discover a specific table by name
*/
public function discoverTable(string $tableName): ?TableMetadata
{
$driver = $this->getDriver();
if ($driver === 'pgsql') {
return $this->discoverPostgreSQLTable($tableName);
}
return $this->discoverMySQLTable($tableName);
}
/**
* Discover a specific PostgreSQL table
*/
private function discoverPostgreSQLTable(string $tableName): ?TableMetadata
{
$sql = "
SELECT
t.tablename as table_name,
COALESCE(s.n_live_tup::bigint, 0) as table_rows,
ROUND(pg_total_relation_size('public.'||?) / 1024.0 / 1024.0, 2) as size_mb,
NULL as engine,
NULL as table_collation
FROM pg_tables t
LEFT JOIN pg_stat_user_tables s ON s.relname = t.tablename
WHERE t.schemaname = 'public'
AND t.tablename = ?
";
$query = SqlQuery::create($sql, [$tableName, $tableName]);
$result = $this->getConnection()->query($query);
$row = $result->fetch();
if ($row === null) {
return null;
}
return new TableMetadata(
name: $row['table_name'],
rowCount: isset($row['table_rows']) && $row['table_rows'] !== null ? (int) $row['table_rows'] : null,
sizeMb: isset($row['size_mb']) ? (float) $row['size_mb'] : null,
engine: null,
collation: null,
);
}
/**
* Discover a specific MySQL table
*/
private function discoverMySQLTable(string $tableName): ?TableMetadata
{
$databaseName = $this->getCurrentDatabase();
@@ -75,7 +195,7 @@ final readonly class TableDiscovery
";
$query = SqlQuery::create($sql, [$databaseName, $tableName]);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$row = $result->fetch();
if ($row === null) {
@@ -96,12 +216,28 @@ final readonly class TableDiscovery
*/
private function getCurrentDatabase(): string
{
$sql = "SELECT DATABASE() as db";
$driver = $this->getDriver();
if ($driver === 'pgsql') {
$sql = "SELECT current_database() as db";
} else {
$sql = "SELECT DATABASE() as db";
}
$query = SqlQuery::create($sql, []);
$result = $this->connection->query($query);
$result = $this->getConnection()->query($query);
$row = $result->fetch();
return $row['db'] ?? 'unknown';
}
/**
* Get database driver name
*/
private function getDriver(): string
{
$pdo = $this->getConnection()->getPdo();
return $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
}
}

View File

@@ -7,9 +7,9 @@ namespace App\Framework\Database\Browser\Registry;
use App\Framework\Database\Browser\Discovery\DatabaseDiscovery;
use App\Framework\Database\Browser\ValueObjects\DatabaseMetadata;
final readonly class DatabaseRegistry
final class DatabaseRegistry
{
private ?DatabaseMetadata $currentDatabase;
private ?DatabaseMetadata $currentDatabase = null;
public function __construct(
private DatabaseDiscovery $databaseDiscovery

View File

@@ -7,6 +7,11 @@ namespace App\Framework\Database\Browser\Registry;
use App\Framework\Database\Browser\Discovery\SchemaDiscovery;
use App\Framework\Database\Browser\Discovery\TableDiscovery;
use App\Framework\Database\Browser\ValueObjects\TableMetadata;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Pagination\ValueObjects\PaginationRequest;
use App\Framework\Pagination\ValueObjects\PaginationResponse;
use App\Framework\Pagination\ValueObjects\PaginationMeta;
final class TableRegistry
{
@@ -96,5 +101,50 @@ final class TableRegistry
$this->schemaCache = [];
$this->allTablesLoaded = false;
}
/**
* Get table data rows with pagination
*
* @return PaginationResponse<array<string, mixed>>
*/
public function getTableData(
string $tableName,
PaginationRequest $paginationRequest,
ConnectionInterface $connection
): PaginationResponse {
// Detect database driver for correct identifier quoting
$pdo = $connection->getPdo();
$driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
$quoteChar = $driver === 'pgsql' ? '"' : '`';
// Get total count
$countSql = "SELECT COUNT(*) as total FROM {$quoteChar}{$tableName}{$quoteChar}";
$countQuery = SqlQuery::create($countSql, []);
$countResult = $connection->query($countQuery);
$countRow = $countResult->fetch();
$totalCount = (int) ($countRow['total'] ?? 0);
// Get paginated data
$offset = $paginationRequest->offset;
$limit = $paginationRequest->limit;
// Build ORDER BY clause if sort field is specified
$orderBy = '';
if ($paginationRequest->sortField !== null) {
$direction = $paginationRequest->direction === \App\Framework\Pagination\ValueObjects\Direction::ASC ? 'ASC' : 'DESC';
$orderBy = " ORDER BY {$quoteChar}{$paginationRequest->sortField}{$quoteChar} {$direction}";
}
$dataSql = "SELECT * FROM {$quoteChar}{$tableName}{$quoteChar}{$orderBy} LIMIT ? OFFSET ?";
$dataQuery = SqlQuery::create($dataSql, [$limit, $offset]);
$dataResult = $connection->query($dataQuery);
$rows = $dataResult->fetchAll();
return PaginationResponse::offset(
data: $rows,
request: $paginationRequest,
totalCount: $totalCount
);
}
}

View File

@@ -30,6 +30,12 @@ final readonly class DatabaseMetadata
public function toArray(): array
{
// Convert size_mb to Byte object for human-readable formatting
$sizeByte = null;
if ($this->sizeMb !== null) {
$sizeByte = \App\Framework\Core\ValueObjects\Byte::fromMegabytes($this->sizeMb);
}
return [
'name' => $this->name,
'engine' => $this->engine,
@@ -37,6 +43,7 @@ final readonly class DatabaseMetadata
'collation' => $this->collation,
'table_count' => $this->tableCount,
'size_mb' => $this->sizeMb,
'size' => $sizeByte?->toHumanReadable(), // Add human-readable size
];
}
}

View File

@@ -76,10 +76,17 @@ final readonly class TableMetadata
public function toArray(): array
{
// Convert size_mb to Byte object for human-readable formatting
$sizeByte = null;
if ($this->sizeMb !== null) {
$sizeByte = \App\Framework\Core\ValueObjects\Byte::fromMegabytes($this->sizeMb);
}
return [
'name' => $this->name,
'row_count' => $this->rowCount,
'size_mb' => $this->sizeMb,
'size' => $sizeByte?->toHumanReadable(), // Add human-readable size
'engine' => $this->engine,
'collation' => $this->collation,
'columns' => array_map(fn (ColumnMetadata $c) => $c->toArray(), $this->columns),

View File

@@ -24,9 +24,9 @@ final readonly class DatabaseConfigInitializer
driverType: DriverType::from($this->env->getString(EnvKey::DB_DRIVER, 'pgsql')),
host: $this->env->getString(EnvKey::DB_HOST, 'db'),
port: $this->env->getInt(EnvKey::DB_PORT, 5432),
database: $this->env->getRequired(EnvKey::DB_DATABASE),
username: $this->env->getRequired(EnvKey::DB_USERNAME),
password: $this->env->getRequired(EnvKey::DB_PASSWORD),
database: $this->env->getString(EnvKey::DB_DATABASE, ''),
username: $this->env->getString(EnvKey::DB_USERNAME, ''),
password: $this->env->getString(EnvKey::DB_PASSWORD, ''),
charset: $this->env->getString(EnvKey::DB_CHARSET, 'utf8'),
);

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Async\AsyncService;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\Database\Config\PoolConfig;
use App\Framework\Database\Config\ReadWriteConfig;
use App\Framework\Database\Driver\DriverConfig;
use App\Framework\Database\Driver\DriverType;
use App\Framework\Database\Platform\MySQLPlatform;
use App\Framework\Database\Platform\PostgreSQLPlatform;
use App\Framework\DateTime\SystemTimer;
/**
* Connection Factory
*
* Zentrale Factory für die Erstellung von ConnectionInterface Instanzen.
* Dies ist die einzige Quelle für Connection-Erstellung und vermeidet Code-Duplikation
* zwischen ConnectionInitializer und EntityManagerInitializer.
*/
final readonly class ConnectionFactory
{
public function __construct(
private ?AsyncService $asyncService = null,
private bool $enableAsync = true
) {
}
/**
* Create ConnectionInterface from an existing DatabaseManager
*
* This is the preferred method when DatabaseManager already exists
* (e.g., created by EntityManagerInitializer).
*/
public function createFromDatabaseManager(DatabaseManager $databaseManager): ConnectionInterface
{
$connection = $databaseManager->getConnection();
// Automatically wrap with AsyncAwareConnection if AsyncService is available
if ($this->enableAsync && $this->asyncService !== null) {
return new AsyncAwareConnection($connection, $this->asyncService);
}
return $connection;
}
/**
* Create ConnectionInterface with minimal dependencies
*
* Used as fallback when DatabaseManager doesn't exist yet.
* This creates a standalone DatabaseManager without MigrationLoader/MigrationRunnerFactory
* to avoid circular dependencies.
*/
public function createStandalone(Environment $environment): ConnectionInterface
{
$databaseConfig = $this->createDatabaseConfig($environment);
$timer = new SystemTimer();
$platform = match ($databaseConfig->driverConfig->driverType) {
DriverType::MYSQL => new MySQLPlatform(),
DriverType::PGSQL => new PostgreSQLPlatform(),
DriverType::SQLITE => throw new \RuntimeException('SQLite platform not yet implemented'),
default => throw new \RuntimeException("Unsupported database driver: {$databaseConfig->driverConfig->driverType->value}")
};
$databaseManager = new DatabaseManager(
config: $databaseConfig,
platform: $platform,
timer: $timer,
migrationsPath: 'database/migrations'
// Do NOT pass MigrationLoader or MigrationRunnerFactory to avoid circular dependencies
);
return $this->createFromDatabaseManager($databaseManager);
}
/**
* Create DatabaseConfig directly from Environment without using container->get()
* This avoids circular dependencies.
*/
private function createDatabaseConfig(Environment $environment): DatabaseConfig
{
$driverConfig = new DriverConfig(
driverType: DriverType::from($environment->getString(EnvKey::DB_DRIVER, 'pgsql')),
host: $environment->getString(EnvKey::DB_HOST, 'db'),
port: $environment->getInt(EnvKey::DB_PORT, 5432),
database: $environment->getString(EnvKey::DB_DATABASE, ''),
username: $environment->getString(EnvKey::DB_USERNAME, ''),
password: $environment->getString(EnvKey::DB_PASSWORD, ''),
charset: $environment->getString(EnvKey::DB_CHARSET, 'utf8'),
);
$poolConfig = new PoolConfig(
enabled: true,
maxConnections: 10,
minConnections: 2
);
$readWriteConfig = new ReadWriteConfig(
enabled: false, // Disabled by default, can be enabled via env vars
readConnections: []
);
return new DatabaseConfig(
driverConfig: $driverConfig,
poolConfig: $poolConfig,
readWriteConfig: $readWriteConfig,
);
}
}

View File

@@ -5,15 +5,22 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Async\AsyncService;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\DateTime\Timer;
use App\Framework\Config\Environment;
use App\Framework\DI\Container;
use App\Framework\DI\Exceptions\CyclicDependencyException;
use App\Framework\DI\Initializer;
/**
* Connection Initializer
*
* Creates ConnectionInterface instances. Uses ConnectionFactory for centralized logic.
* This initializer is designed to work alongside EntityManagerInitializer:
* - If DatabaseManager already exists (created by EntityManagerInitializer), reuse it
* - Otherwise, create a standalone connection with minimal dependencies
*/
final readonly class ConnectionInitializer
{
public function __construct(
private ?AsyncService $asyncService = null,
private bool $enableAsync = true
) {
}
@@ -21,29 +28,81 @@ final readonly class ConnectionInitializer
#[Initializer]
public function __invoke(Container $container): ConnectionInterface
{
// Check if EntityManagerInitializer already registered a connection
if ($container->has(DatabaseManager::class)) {
$connection = $container->get(DatabaseManager::class)->getConnection();
} else {
$databaseConfig = $container->get(DatabaseConfig::class);
$timer = $container->get(Timer::class);
// IMPORTANT: Never call container->get(ConnectionInterface::class) here!
// That would cause a circular dependency because we ARE the initializer for ConnectionInterface.
// IMPORTANT: This initializer should only run if ConnectionInterface is requested
// BEFORE EntityManager is initialized. If EntityManagerInitializer has already run,
// it will have registered ConnectionInterface as a lazy factory that calls
// DatabaseManager->getConnection(), so this initializer should not be called.
//
// However, if ConnectionInterface is requested directly (e.g., by AdminContentsController
// before EntityManager is requested), this initializer will run.
//
// In this case, we create a standalone connection with minimal dependencies.
// This avoids circular dependencies by not requiring DatabaseManager or any
// of its dependencies (like MigrationLoader, MigrationRunnerFactory, etc.).
$environment = $this->getEnvironment($container);
$connectionFactory = new ConnectionFactory(
asyncService: null,
enableAsync: false // Disable async for standalone connections to avoid cycles
);
return $connectionFactory->createStandalone($environment);
}
// Create a simple database manager for connection only with minimal dependencies
$databaseManager = new DatabaseManager(
config: $databaseConfig,
platform: $databaseConfig->driverConfig->platform,
timer: $timer,
migrationsPath: 'database/migrations'
);
$connection = $databaseManager->getConnection();
/**
* Get Environment instance from container
*
* IMPORTANT: Environment is registered as a singleton very early by AppBootstrapper
* and has no dependencies that require ConnectionInterface. Therefore, we can safely
* retrieve it from the container if it exists.
*
* If Environment doesn't exist yet (edge case), we fall back to creating from $_ENV.
*
* @return Environment
*/
private function getEnvironment(Container $container): Environment
{
// Try to get Environment from container if it exists as singleton
// Use ContainerIntrospector to check without triggering initialization
$introspector = $container->introspector;
if ($introspector->isSingleton(Environment::class)) {
try {
// If it's registered as singleton, container->get() should return the existing instance
// without triggering initialization (see DefaultContainer::get() lines 112-114)
$environment = $container->get(Environment::class);
if ($environment instanceof Environment) {
return $environment;
}
} catch (CyclicDependencyException) {
// If we get a cyclic dependency, fall back to loading Environment directly
}
}
// Automatically wrap with AsyncAwareConnection if AsyncService is available
if ($this->enableAsync && $this->asyncService !== null) {
return new AsyncAwareConnection($connection, $this->asyncService);
// Fallback: Load Environment directly using EncryptedEnvLoader
// This loads .env files without using the container, avoiding circular dependencies
// We need to get the base path, which should be available via PathProvider
try {
// Try to get PathProvider if it exists (it's registered early and doesn't need ConnectionInterface)
if ($container->hasInstance(PathProvider::class)) {
try {
$pathProvider = $container->get(PathProvider::class);
if ($pathProvider instanceof PathProvider) {
$loader = new EncryptedEnvLoader();
return $loader->load($pathProvider->getBasePath()->toString());
}
} catch (CyclicDependencyException) {
// If we get a cyclic dependency, fall back to $_ENV
}
}
} catch (\Throwable) {
// If anything goes wrong, fall back to $_ENV
}
return $connection;
// Final fallback: Create Environment directly from $_ENV
// This won't load .env files, but Docker ENV vars should have the DB credentials
return new Environment($_ENV);
}
}

View File

@@ -9,6 +9,7 @@ use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\Database\Migration\MigrationLoader;
use App\Framework\Database\Migration\MigrationRunner;
use App\Framework\Database\Migration\MigrationRunnerFactory;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\Profiling\ProfileSummary;
use App\Framework\Database\Profiling\ProfilingConnection;
@@ -20,6 +21,8 @@ use App\Framework\Database\ReadWrite\ReplicationLagDetector;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
use App\Framework\DateTime\Timer;
use App\Framework\Database\Exception\ConnectionFailedException;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Logging\Logger;
final class DatabaseManager
@@ -35,12 +38,17 @@ final class DatabaseManager
private readonly string $migrationsPath = 'database/migrations',
private readonly ?Clock $clock = null,
private readonly ?Logger $logger = null,
private readonly ?EventDispatcher $eventDispatcher = null
private readonly ?EventDispatcher $eventDispatcher = null,
private readonly ?MigrationLoader $migrationLoader = null,
private readonly ?MigrationRunnerFactory $migrationRunnerFactory = null
) {
}
public function getConnection(): ConnectionInterface
{
// Validate database credentials before attempting connection
$this->validateDatabaseCredentials();
$connection = null;
if ($this->config->poolConfig->enabled) {
@@ -59,6 +67,25 @@ final class DatabaseManager
return $connection;
}
/**
* Validate that database credentials are configured
*
* @throws ConnectionFailedException If database credentials are missing
*/
private function validateDatabaseCredentials(): void
{
$driverConfig = $this->config->driverConfig;
if (empty($driverConfig->database) || empty($driverConfig->username) || empty($driverConfig->password)) {
throw ConnectionFailedException::cannotConnect(
host: $driverConfig->host,
database: $driverConfig->database ?: '(not configured)',
sqlState: new SqlState('08001'),
errorMessage: 'Database credentials are not configured. Please set DB_DATABASE, DB_USERNAME, and DB_PASSWORD environment variables.'
);
}
}
public function getPooledConnection(): PooledConnection
{
if ($this->connectionPool === null) {
@@ -154,15 +181,14 @@ final class DatabaseManager
public function migrate(?string $migrationsPath = null): array
{
$migrationsPath = $migrationsPath ?? $this->migrationsPath;
if (! $this->migrationLoader || ! $this->migrationRunnerFactory) {
throw new \RuntimeException('MigrationLoader and MigrationRunnerFactory must be injected to use migration methods');
}
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$migrations = $this->migrationLoader->loadMigrations();
$runner = new MigrationRunner(
$runner = $this->migrationRunnerFactory->create(
connection: $this->getConnection(),
platform: $this->platform,
clock: $this->clock ?? new SystemClock(),
tableConfig: null,
logger: $this->logger
);
@@ -172,15 +198,14 @@ final class DatabaseManager
public function rollback(?string $migrationsPath = null, int $steps = 1): array
{
$migrationsPath = $migrationsPath ?? $this->migrationsPath;
if (! $this->migrationLoader || ! $this->migrationRunnerFactory) {
throw new \RuntimeException('MigrationLoader and MigrationRunnerFactory must be injected to use migration methods');
}
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$migrations = $this->migrationLoader->loadMigrations();
$runner = new MigrationRunner(
$runner = $this->migrationRunnerFactory->create(
connection: $this->getConnection(),
platform: $this->platform,
clock: $this->clock ?? new SystemClock(),
tableConfig: null,
logger: $this->logger
);
@@ -190,15 +215,14 @@ final class DatabaseManager
public function getMigrationStatus(?string $migrationsPath = null): array
{
$migrationsPath = $migrationsPath ?? $this->migrationsPath;
if (! $this->migrationLoader || ! $this->migrationRunnerFactory) {
throw new \RuntimeException('MigrationLoader and MigrationRunnerFactory must be injected to use migration methods');
}
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$migrations = $this->migrationLoader->loadMigrations();
$runner = new MigrationRunner(
$runner = $this->migrationRunnerFactory->create(
connection: $this->getConnection(),
platform: $this->platform,
clock: $this->clock ?? new SystemClock(),
tableConfig: null,
logger: $this->logger
);

View File

@@ -34,6 +34,14 @@ final readonly class EntityManagerInitializer
// Create platform for the database (defaulting to MySQL)
$platform = new PostgreSQLPlatform();
// Get MigrationLoader and MigrationRunnerFactory if available
$migrationLoader = null;
$migrationRunnerFactory = null;
if ($container->has(MigrationLoader::class) && $container->has(MigrationRunnerFactory::class)) {
$migrationLoader = $container->get(MigrationLoader::class);
$migrationRunnerFactory = $container->get(MigrationRunnerFactory::class);
}
$db = new DatabaseManager(
$databaseConfig,
$platform,
@@ -41,7 +49,9 @@ final readonly class EntityManagerInitializer
'database/migrations',
$clock,
$logger,
$eventDispatcher
$eventDispatcher,
$migrationLoader,
$migrationRunnerFactory
);
$container->singleton(DatabaseManager::class, $db);

View File

@@ -11,8 +11,9 @@ use App\Framework\Console\ExitCode;
final readonly class ApplyMigrations
{
public function __construct(
private MigrationRunner $runner,
private MigrationLoader $loader
private MigrationRunnerFactory $runnerFactory,
private MigrationLoader $loader,
private \App\Framework\Database\ConnectionInterface $connection
) {
}
@@ -27,7 +28,9 @@ final readonly class ApplyMigrations
// Use the injected loader that leverages the discovery system
$migrations = $this->loader->loadMigrations();
$executed = $this->runner->migrate($migrations, $skipPreflightChecks);
// Create runner using factory
$runner = $this->runnerFactory->create($this->connection);
$executed = $runner->migrate($migrations, $skipPreflightChecks);
if (empty($executed)) {
echo "No migrations to run.\n";
@@ -56,7 +59,9 @@ final readonly class ApplyMigrations
// Use the injected loader that leverages the discovery system
$migrations = $this->loader->loadMigrations();
$rolledBack = $this->runner->rollback($migrations, $steps);
// Create runner using factory
$runner = $this->runnerFactory->create($this->connection);
$rolledBack = $runner->rollback($migrations, $steps);
if (empty($rolledBack)) {
echo "✅ No migrations to roll back.\n";
@@ -97,7 +102,10 @@ final readonly class ApplyMigrations
// Use the injected loader that leverages the discovery system
$migrations = $this->loader->loadMigrations();
$statusCollection = $this->runner->getStatus($migrations);
// Create runner using factory
$runner = $this->runnerFactory->create($this->connection);
$statusCollection = $runner->getStatus($migrations);
foreach ($statusCollection as $migrationStatus) {
echo sprintf(

Some files were not shown because too many files have changed in this diff Show More