Files
myep/src/Form/Service/ParticipantFieldHandlerRegistry.php
T

379 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
/**
* Registry for managing and executing participant field handlers in dependency order.
*
* This class ensures that participant field handlers are executed in the correct order
* based on their dependencies, preventing issues where a field depends on
* another field that hasn't been processed yet.
*/
class ParticipantFieldHandlerRegistry
{
/** @var array<string, ParticipantFieldHandlerInterface> Registered handlers indexed by field name */
private array $handlers = [];
/** @var string[]|null Cached array of handler names sorted by dependency order */
private ?array $sortedHandlers = null;
/**
* Initializes the registry with a hybrid array of handlers.
*
* This constructor supports both simple handlers (passed as class names) and
* complex handlers (passed as instantiated service objects). Simple handlers
* with no dependencies can be passed as strings and will be instantiated
* automatically. Complex handlers with dependencies should be passed as
* already-instantiated objects via dependency injection.
*
* @param array<ParticipantFieldHandlerInterface|string> $handlers Array of handler instances or class names
*
* @throws \Exception If a class name cannot be instantiated
*/
public function __construct(array $handlers)
{
foreach ($handlers as $handler) {
if (is_string($handler)) {
// It's a class name - instantiate it (for simple handlers without dependencies)
$handler = new $handler();
}
// If it's already an object (service), use as-is (for complex handlers with dependencies)
$this->addHandler($handler);
}
}
/**
* Registers a participant field handler with the registry.
*
* Handlers are indexed by their field name to ensure uniqueness and enable
* fast lookups. Adding a handler invalidates the dependency sort cache,
* forcing a re-sort on the next processing request.
*
* @param ParticipantFieldHandlerInterface $handler The handler to register
*/
public function addHandler(ParticipantFieldHandlerInterface $handler): void
{
$this->handlers[$handler->getFieldName()] = $handler;
$this->sortedHandlers = null; // Reset cache to force re-sorting with new handler
}
/**
* Processes all participant fields and synchronizes submitted data with cleaned DTO state.
*
* This method combines field processing with data synchronization to ensure that
* the submitted form data reflects any changes made by field handlers. This is
* particularly useful for HTMX form updates where invalid selections need to be
* automatically cleared.
*
* @param array<string, mixed> $submittedData The submitted form data containing participants array
* @param BookingDtoInterface $bookingDto The booking DTO to update with processed field values
*
* @return array<string, mixed> The synchronized submitted data reflecting DTO changes
*/
public function processFieldsAndSync(array $submittedData, BookingDtoInterface $bookingDto): array
{
// Process all field handlers to clean the DTO
$this->processFields($submittedData, $bookingDto);
// Synchronize submitted data with the cleaned DTO state
return $this->syncSubmittedDataWithDto($submittedData, $bookingDto);
}
/**
* Processes all participant fields from submitted form data using registered handlers.
*
* This is the main entry point for field processing. It processes handlers in dependency
* order, applying each handler to ALL participants before moving to the next handler.
* This ensures that cross-participant logic (like family booking detection) has access
* to complete data from all participants.
*
* Processing order: handler-first, then participants
* - Process handler A for all participants
* - Process handler B for all participants
* - etc.
*
* This is critical for handlers that depend on booking-level state (like insurance
* family detection which needs all participants' ages to be processed first).
*
* @param array<string, mixed> $submittedData The submitted form data containing participants array
* @param BookingDtoInterface $bookingDto The booking DTO to update with processed field values (create or edit)
*/
public function processFields(array $submittedData, BookingDtoInterface $bookingDto): void
{
// Early return if no participant data exists in submission
if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) {
return;
}
// Get handlers sorted by dependency order (uses cache if available)
$sortedHandlerNames = $this->getSortedHandlers();
// Process each handler across all participants before moving to the next handler
// This ensures booking-level state (like family booking detection) is accurate
foreach ($sortedHandlerNames as $handlerName) {
$handler = $this->handlers[$handlerName];
// Apply this handler to all participants
foreach ($submittedData['participants'] as $participantIndex => $participantData) {
// Skip invalid participant data
if (false === is_array($participantData)) {
continue;
}
// Let each handler decide if it should process this participant's data
if ($handler->shouldProcess($participantData, (int) $participantIndex)) {
$handler->processField($participantData, $bookingDto, (int) $participantIndex);
}
}
}
}
/**
* Returns handler names sorted by dependency order using cached results when possible.
*
* This method implements lazy loading with caching for performance. The dependency
* sort is only performed once and the result is cached until handlers are added
* or modified.
*
* @return string[] Array of handler field names in dependency execution order
*/
private function getSortedHandlers(): array
{
// Return cached result if available
if (null !== $this->sortedHandlers) {
return $this->sortedHandlers;
}
// Perform topological sort and cache the result
$this->sortedHandlers = $this->topologicalSort();
return $this->sortedHandlers;
}
/**
* Performs topological sort to determine safe handler execution order.
*
* This method uses Kahn's algorithm to sort handlers based on their declared
* dependencies. It ensures that no handler is executed before its dependencies
* have been processed, preventing data consistency issues.
*
* The algorithm works by:
* 1. Building a dependency graph of handlers
* 2. Finding handlers with no dependencies (in-degree = 0)
* 3. Iteratively removing handlers and updating dependencies
* 4. Detecting circular dependencies (deadlock prevention)
*
* @return string[] Array of handler field names in safe execution order
*
* @throws \InvalidArgumentException When a handler depends on a non-existent handler
* @throws \InvalidArgumentException When circular dependencies are detected
*/
private function topologicalSort(): array
{
$inDegree = []; // Count of dependencies for each handler
$graph = []; // Adjacency list of handler dependencies
$handlerNames = array_keys($this->handlers);
// Initialize all handlers with zero dependencies
foreach ($handlerNames as $handlerName) {
$inDegree[$handlerName] = 0;
$graph[$handlerName] = [];
}
// Build dependency graph by examining each handler's dependencies
foreach ($this->handlers as $handlerName => $handler) {
foreach ($handler->getDependencies() as $dependency) {
// Validate that the dependency exists
if (false === isset($this->handlers[$dependency])) {
throw new \InvalidArgumentException(sprintf('Handler "%s" depends on unknown handler "%s"', $handlerName, $dependency));
}
// Add edge from dependency to dependent handler
$graph[$dependency][] = $handlerName;
++$inDegree[$handlerName];
}
}
// Kahn's topological sort algorithm
$queue = []; // Handlers ready to be processed (no remaining dependencies)
$result = []; // Final sorted order
// Start with handlers that have no dependencies
foreach ($inDegree as $handlerName => $degree) {
if (0 === $degree) {
$queue[] = $handlerName;
}
}
// Process handlers in dependency order
while (false === empty($queue)) {
$current = array_shift($queue);
$result[] = $current;
// Remove this handler's dependencies from dependent handlers
foreach ($graph[$current] as $dependent) {
--$inDegree[$dependent];
// If dependent now has no remaining dependencies, add to queue
if (0 === $inDegree[$dependent]) {
$queue[] = $dependent;
}
}
}
// Detect circular dependencies (if not all handlers were processed)
if (count($result) !== count($handlerNames)) {
throw new \InvalidArgumentException('Circular dependency detected in participant field handlers');
}
return $result;
}
/**
* Synchronizes submitted data with the cleaned DTO state.
*
* This method updates the submitted form data to reflect any changes made by
* field handlers (such as clearing invalid service selections). This ensures
* that the form continues processing with cleaned data rather than the original
* submitted data that may contain invalid selections.
*
* The synchronization is generic and works with any field handlers by examining
* the current DTO state and updating the corresponding submitted data fields.
*
* @param array<string, mixed> $submittedData The original submitted form data
* @param BookingDtoInterface $bookingDto The DTO with cleaned data from field handlers
*
* @return array<string, mixed> Updated submitted data reflecting DTO state
*/
private function syncSubmittedDataWithDto(array $submittedData, BookingDtoInterface $bookingDto): array
{
// Ensure participants array exists in submitted data
if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) {
return $submittedData;
}
// Sync each participant's data with the cleaned DTO
foreach ($submittedData['participants'] as $index => $participantData) {
if (false === is_array($participantData)) {
continue;
}
$participant = $bookingDto->getParticipant((int) $index);
if (null === $participant) {
continue;
}
// Update participant data to match cleaned DTO state
$submittedData['participants'][$index] = $this->syncParticipantData($participantData, $participant);
}
return $submittedData;
}
/**
* Synchronizes individual participant submitted data with cleaned participant DTO.
*
* This method examines the participant DTO and updates the submitted data to match
* any changes made by field handlers. It automatically detects which fields have
* been processed by checking against registered handlers.
*
* @param array<string, mixed> $participantData The submitted participant data
* @param ParticipantDto $participant The cleaned participant DTO
*
* @return array<string, mixed> Updated participant data with synchronized field values
*/
private function syncParticipantData(array $participantData, ParticipantDto $participant): array
{
// Sync fields for all registered handlers
foreach ($this->handlers as $fieldName => $handler) {
// Only sync fields that were in the original submission
// This prevents adding extra fields that would cause validation errors
if (property_exists($participant, $fieldName) && array_key_exists($fieldName, $participantData)) {
$dtoValue = $participant->{$fieldName};
$participantData[$fieldName] = $this->convertDtoValueToSubmittedFormat($dtoValue);
}
}
return $participantData;
}
/**
* Converts DTO field values to the format expected in submitted form data.
*
* This method handles the conversion from DTO field values to the format
* that Symfony forms expect in submitted data. It supports various data types
* including service objects, arrays, and primitive values.
*
* @param mixed $dtoValue The field value from the DTO
*
* @return mixed The value in submitted data format
*/
private function convertDtoValueToSubmittedFormat(mixed $dtoValue): mixed
{
// Handle null values
if (null === $dtoValue) {
return null;
}
// Handle arrays (service collections, etc.)
if (is_array($dtoValue)) {
$submittedFormat = [];
foreach ($dtoValue as $item) {
$submittedFormat[] = $this->convertSingleValueToSubmittedFormat($item);
}
return $submittedFormat;
}
// Handle single values
return $this->convertSingleValueToSubmittedFormat($dtoValue);
}
/**
* Converts a single DTO value to submitted form format.
*
* @param mixed $value The value to convert
*
* @return mixed The converted value
*/
private function convertSingleValueToSubmittedFormat(mixed $value): mixed
{
// Handle Service objects -> convert to ID
if ($value instanceof Service) {
return $value->id;
}
// Handle Pickup objects -> convert to ID
if ($value instanceof Pickup) {
return $value->id;
}
// Handle Insurance objects -> convert to ID
if ($value instanceof Insurance) {
return $value->id;
}
// Handle DateTimeInterface -> convert to string format
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
}
// Handle numeric values
if (is_numeric($value)) {
return $value;
}
// Handle strings and other primitive types
return $value;
}
}