fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
186
src/Framework/Admin/Factories/RepositoryAdapterFactory.php
Normal file
186
src/Framework/Admin/Factories/RepositoryAdapterFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
100
src/Framework/Admin/FormFields/Fields/FileField.php
Normal file
100
src/Framework/Admin/FormFields/Fields/FileField.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
78
src/Framework/Attributes/AfterExecute.php
Normal file
78
src/Framework/Attributes/AfterExecute.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
77
src/Framework/Attributes/BeforeExecute.php
Normal file
77
src/Framework/Attributes/BeforeExecute.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
176
src/Framework/Attributes/Execution/AttributeRunner.php
Normal file
176
src/Framework/Attributes/Execution/AttributeRunner.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
140
src/Framework/Attributes/Execution/CallbackExecutor.php
Normal file
140
src/Framework/Attributes/Execution/CallbackExecutor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
139
src/Framework/Attributes/Execution/CallbackMetadata.php
Normal file
139
src/Framework/Attributes/Execution/CallbackMetadata.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
144
src/Framework/Attributes/Execution/CallbackMetadataExtractor.php
Normal file
144
src/Framework/Attributes/Execution/CallbackMetadataExtractor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
23
src/Framework/Attributes/Execution/CallbackType.php
Normal file
23
src/Framework/Attributes/Execution/CallbackType.php
Normal 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';
|
||||
}
|
||||
|
||||
26
src/Framework/Attributes/Execution/ExecutableAttribute.php
Normal file
26
src/Framework/Attributes/Execution/ExecutableAttribute.php
Normal 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;
|
||||
}
|
||||
|
||||
192
src/Framework/Attributes/Execution/HandlerAttributeExecutor.php
Normal file
192
src/Framework/Attributes/Execution/HandlerAttributeExecutor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
src/Framework/Attributes/Execution/Policies/Policies.php
Normal file
100
src/Framework/Attributes/Execution/Policies/Policies.php
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
63
src/Framework/Attributes/Execution/Policies/UserPolicies.php
Normal file
63
src/Framework/Attributes/Execution/Policies/UserPolicies.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
77
src/Framework/Attributes/Guard.php
Normal file
77
src/Framework/Attributes/Guard.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
77
src/Framework/Attributes/OnBoot.php
Normal file
77
src/Framework/Attributes/OnBoot.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
78
src/Framework/Attributes/OnError.php
Normal file
78
src/Framework/Attributes/OnError.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
87
src/Framework/Attributes/Validate.php
Normal file
87
src/Framework/Attributes/Validate.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ final readonly class FileCache implements CacheDriver, Scannable
|
||||
) {
|
||||
$this->cachePath = $this->normalizeCachePath(
|
||||
$cachePath
|
||||
?? $pathProvider?->getCachePath()
|
||||
?? $pathProvider?->getCachePath()?->toString()
|
||||
?? $this->detectCachePath()
|
||||
);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
src/Framework/Composer/Exception/ComposerLockException.php
Normal file
42
src/Framework/Composer/Exception/ComposerLockException.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
58
src/Framework/Composer/Services/ComposerLockReader.php
Normal file
58
src/Framework/Composer/Services/ComposerLockReader.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
src/Framework/Composer/Services/ComposerManifestReader.php
Normal file
58
src/Framework/Composer/Services/ComposerManifestReader.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
141
src/Framework/Composer/ValueObjects/ComposerLock.php
Normal file
141
src/Framework/Composer/ValueObjects/ComposerLock.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
150
src/Framework/Composer/ValueObjects/ComposerManifest.php
Normal file
150
src/Framework/Composer/ValueObjects/ComposerManifest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
275
src/Framework/Console/Commands/InitializersCheckCommand.php
Normal file
275
src/Framework/Console/Commands/InitializersCheckCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
49
src/Framework/Core/BootstrapProfile.php
Normal file
49
src/Framework/Core/BootstrapProfile.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
135
src/Framework/Core/RequestLifecycleObserver.php
Normal file
135
src/Framework/Core/RequestLifecycleObserver.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
57
src/Framework/Core/RouteCompilerInitializer.php
Normal file
57
src/Framework/Core/RouteCompilerInitializer.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
67
src/Framework/Core/RouteFactory/DynamicRouteFactory.php
Normal file
67
src/Framework/Core/RouteFactory/DynamicRouteFactory.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
37
src/Framework/Core/RouteFactory/RouteFactory.php
Normal file
37
src/Framework/Core/RouteFactory/RouteFactory.php
Normal 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;
|
||||
}
|
||||
|
||||
37
src/Framework/Core/RouteFactory/StaticRouteFactory.php
Normal file
37
src/Framework/Core/RouteFactory/StaticRouteFactory.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
19
src/Framework/Core/ValueObjects/RequestLifecycleResult.php
Normal file
19
src/Framework/Core/ValueObjects/RequestLifecycleResult.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
35
src/Framework/DI/ContainerCacheManager.php
Normal file
35
src/Framework/DI/ContainerCacheManager.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
121
src/Framework/DI/InterfaceInitializerResolver.php
Normal file
121
src/Framework/DI/InterfaceInitializerResolver.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/Framework/DI/ProactiveInitializerFinderFactory.php
Normal file
68
src/Framework/DI/ProactiveInitializerFinderFactory.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
81
src/Framework/DI/ResolutionPipeline.php
Normal file
81
src/Framework/DI/ResolutionPipeline.php
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/Framework/DI/ValueObjects/CycleDetectionResult.php
Normal file
46
src/Framework/DI/ValueObjects/CycleDetectionResult.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
66
src/Framework/DI/ValueObjects/InitializerProblem.php
Normal file
66
src/Framework/DI/ValueObjects/InitializerProblem.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
50
src/Framework/DI/ValueObjects/InitializerWarning.php
Normal file
50
src/Framework/DI/ValueObjects/InitializerWarning.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
|
||||
116
src/Framework/Database/ConnectionFactory.php
Normal file
116
src/Framework/Database/ConnectionFactory.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user