diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 42d71869..11353202 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -29,7 +29,9 @@ use App\Entity\Parts\Part; use App\Entity\Parts\Supplier; use App\Form\InfoProviderSystem\GlobalFieldMappingType; use App\Services\InfoProviderSystem\BulkInfoProviderService; +use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; use App\Services\InfoProviderSystem\DTOs\FieldMappingDTO; +use App\Services\InfoProviderSystem\DTOs\PartSearchResultsDTO; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -99,25 +101,20 @@ class BulkInfoProviderImportController extends AbstractController return $job; } - private function updatePartSearchResults(BulkInfoProviderImportJob $job, int $partId, ?array $newResults): void + private function updatePartSearchResults(BulkInfoProviderImportJob $job, int $partId, ?PartSearchResultsDTO $newResults): void { if ($newResults === null) { return; } // Only deserialize and update if we have new results - $allResults = $job->deserializeSearchResults($this->entityManager); + $allResults = $job->getSearchResults($this->entityManager); // Find and update the results for this specific part - foreach ($allResults as $index => $partResult) { - if ($partResult['part']->getId() === $partId) { - $allResults[$index] = $newResults; - break; - } - } + $allResults = $allResults->replaceResultsForPart($partId, $newResults); // Save updated results back to job - $job->setSearchResults($job->serializeSearchResults($allResults)); + $job->setSearchResults($allResults); } #[Route('/step1', name: 'bulk_info_provider_step1')] @@ -219,17 +216,14 @@ class BulkInfoProviderImportController extends AbstractController $fieldMappingDtos = $this->convertFieldMappingsToDto($fieldMappings); $searchResultsDto = $this->bulkService->performBulkSearch($parts, $fieldMappingDtos, $prefetchDetails); - // Convert DTO back to array format for legacy compatibility - $searchResults = $searchResultsDto->toArray(); - // Save search results to job - $job->setSearchResults($job->serializeSearchResults($searchResults)); + $job->setSearchResults($searchResultsDto); $job->markAsInProgress(); $this->entityManager->flush(); // Prefetch details if requested if ($prefetchDetails) { - $this->bulkService->prefetchDetailsForResults($searchResults); + $this->bulkService->prefetchDetailsForResults($searchResultsDto); } return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]); @@ -369,7 +363,7 @@ class BulkInfoProviderImportController extends AbstractController // Get the parts and deserialize search results $parts = $job->getJobParts()->map(fn($jobPart) => $jobPart->getPart())->toArray(); - $searchResults = $job->deserializeSearchResults($this->entityManager); + $searchResults = $job->getSearchResults($this->entityManager); return $this->render('info_providers/bulk_import/step2.html.twig', [ 'job' => $job, @@ -481,34 +475,33 @@ class BulkInfoProviderImportController extends AbstractController try { $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails); - $searchResults = $searchResultsDto->toArray(); } catch (\Exception $searchException) { // Handle "no search results found" as a normal case, not an error if (str_contains($searchException->getMessage(), 'No search results found')) { - $searchResults = []; + $searchResultsDto = null; } else { throw $searchException; } } // Update the job's search results for this specific part efficiently - $this->updatePartSearchResults($job, $partId, $searchResults[0] ?? null); + $this->updatePartSearchResults($job, $partId, $searchResultsDto[0] ?? null); // Prefetch details if requested - if ($prefetchDetails && !empty($searchResults)) { - $this->bulkService->prefetchDetailsForResults($searchResults); + if ($prefetchDetails && $searchResultsDto !== null) { + $this->bulkService->prefetchDetailsForResults($searchResultsDto); } $this->entityManager->flush(); // Return the new results for this part - $newResults = $searchResults[0] ?? null; + $newResults = $searchResultsDto[0] ?? null; return $this->json([ 'success' => true, 'part_id' => $partId, - 'results_count' => $newResults ? count($newResults['search_results']) : 0, - 'errors_count' => $newResults ? count($newResults['errors']) : 0, + 'results_count' => $newResults ? $newResults->getResultCount() : 0, + 'errors_count' => $newResults ? $newResults->getErrorCount() : 0, 'message' => 'Part research completed successfully' ]); @@ -555,13 +548,12 @@ class BulkInfoProviderImportController extends AbstractController $prefetchDetails = $job->isPrefetchDetails(); // Process in batches to reduce memory usage for large operations - $allResults = []; + $allResults = new BulkSearchResponseDTO(partResults: []); $batches = array_chunk($parts, $this->bulkImportBatchSize); foreach ($batches as $batch) { $batchResultsDto = $this->bulkService->performBulkSearch($batch, $fieldMappingDtos, $prefetchDetails); - $batchResults = $batchResultsDto->toArray(); - $allResults = array_merge($allResults, $batchResults); + $allResults = BulkSearchResponseDTO::merge($allResults, $batchResultsDto); // Properly manage entity manager memory without losing state $jobId = $job->getId(); @@ -570,7 +562,7 @@ class BulkInfoProviderImportController extends AbstractController } // Update the job's search results - $job->setSearchResults($job->serializeSearchResults($allResults)); + $job->setSearchResults($allResults); // Prefetch details if requested if ($prefetchDetails) { diff --git a/src/Entity/BulkInfoProviderImportJob.php b/src/Entity/BulkInfoProviderImportJob.php index bfbfd88c..0cd97841 100644 --- a/src/Entity/BulkInfoProviderImportJob.php +++ b/src/Entity/BulkInfoProviderImportJob.php @@ -25,6 +25,7 @@ namespace App\Entity; use App\Entity\Base\AbstractDBElement; use App\Entity\Parts\Part; use App\Entity\UserSystem\User; +use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -45,6 +46,11 @@ class BulkInfoProviderImportJob extends AbstractDBElement #[ORM\Column(type: Types::JSON)] private array $searchResults = []; + /** + * @var BulkSearchResponseDTO|null The deserialized search results DTO, cached for performance + */ + private ?BulkSearchResponseDTO $searchResultsDTO = null; + #[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)] private BulkImportJobStatus $status = BulkImportJobStatus::PENDING; @@ -155,17 +161,33 @@ class BulkInfoProviderImportJob extends AbstractDBElement return $this; } - public function getSearchResults(): array + public function getSearchResultsRaw(): array { return $this->searchResults; } - public function setSearchResults(array $searchResults): self + public function setSearchResultsRaw(array $searchResults): self { $this->searchResults = $searchResults; return $this; } + public function setSearchResults(BulkSearchResponseDTO $searchResponse): self + { + $this->searchResultsDTO = $searchResponse; + $this->searchResults = $searchResponse->toSerializableRepresentation(); + return $this; + } + + public function getSearchResults(EntityManagerInterface $entityManager): BulkSearchResponseDTO + { + if ($this->searchResultsDTO === null) { + // Lazy load the DTO from the raw JSON data + $this->searchResultsDTO = BulkSearchResponseDTO::fromSerializableRepresentation($this->searchResults, $entityManager); + } + return $this->searchResultsDTO; + } + public function getStatus(): BulkImportJobStatus { return $this->status; @@ -396,107 +418,4 @@ class BulkInfoProviderImportJob extends AbstractDBElement $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount(); return $completed >= $total; } - - /** - * @param array $searchResults - * @return array{part_id: int, search_results: array{dto: array{provider_key: string, provider_id: string, name: string, description: string, manufacturer: string, mpn: string, provider_url: string, preview_image_url: string, _source_field: string|null, _source_keyword: string|null}, localPart: int|null}[], errors: string[]}[] - */ - public function serializeSearchResults(array $searchResults): array - { - $serialized = []; - - foreach ($searchResults as $partResult) { - $partData = [ - 'part_id' => $partResult['part']->getId(), - 'search_results' => [], - 'errors' => $partResult['errors'] ?? [] - ]; - - foreach ($partResult['search_results'] as $result) { - $dto = $result['dto']; - $partData['search_results'][] = [ - 'dto' => [ - 'provider_key' => $dto->provider_key, - 'provider_id' => $dto->provider_id, - 'name' => $dto->name, - 'description' => $dto->description, - 'manufacturer' => $dto->manufacturer, - 'mpn' => $dto->mpn, - 'provider_url' => $dto->provider_url, - 'preview_image_url' => $dto->preview_image_url, - '_source_field' => $result['source_field'] ?? null, - '_source_keyword' => $result['source_keyword'] ?? null, - ], - 'localPart' => $result['localPart'] ? $result['localPart']->getId() : null - ]; - } - - $serialized[] = $partData; - } - - return $serialized; - } - - /** - * @param EntityManagerInterface|null $entityManager - * @return array{part: Part, search_results: array{dto: SearchResultDTO, localPart: Part|null, source_field: string|null, source_keyword: string|null}[], errors: string[]}[] - */ - public function deserializeSearchResults(?EntityManagerInterface $entityManager = null): array - { - if (empty($this->searchResults)) { - return []; - } - - $parts = $this->jobParts->map(fn($jobPart) => $jobPart->getPart())->toArray(); - $partsById = []; - foreach ($parts as $part) { - $partsById[$part->getId()] = $part; - } - - $searchResults = []; - - foreach ($this->searchResults as $partData) { - $part = $partsById[$partData['part_id']] ?? null; - if (!$part) { - continue; - } - - $partResult = [ - 'part' => $part, - 'search_results' => [], - 'errors' => $partData['errors'] ?? [] - ]; - - foreach ($partData['search_results'] as $resultData) { - $dtoData = $resultData['dto']; - - $dto = new SearchResultDTO( - provider_key: $dtoData['provider_key'], - provider_id: $dtoData['provider_id'], - name: $dtoData['name'], - description: $dtoData['description'], - manufacturer: $dtoData['manufacturer'], - mpn: $dtoData['mpn'], - provider_url: $dtoData['provider_url'], - preview_image_url: $dtoData['preview_image_url'] - ); - - $localPart = null; - if ($resultData['localPart'] && $entityManager) { - $localPart = $entityManager->getRepository(Part::class)->find($resultData['localPart']); - } - - $partResult['search_results'][] = [ - 'dto' => $dto, - 'localPart' => $localPart, - 'source_field' => $dtoData['_source_field'] ?? null, - 'source_keyword' => $dtoData['_source_keyword'] ?? null - ]; - } - - $searchResults[] = $partResult; - } - - return $searchResults; - } } diff --git a/src/Services/InfoProviderSystem/BulkInfoProviderService.php b/src/Services/InfoProviderSystem/BulkInfoProviderService.php index c2b4dc90..dec62f25 100644 --- a/src/Services/InfoProviderSystem/BulkInfoProviderService.php +++ b/src/Services/InfoProviderSystem/BulkInfoProviderService.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem; -use App\Entity\Base\AbstractPartsContainingDBElement; use App\Entity\Parts\Part; use App\Entity\Parts\Supplier; use App\Services\InfoProviderSystem\DTOs\BulkSearchResultDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; use App\Services\InfoProviderSystem\DTOs\FieldMappingDTO; -use App\Services\InfoProviderSystem\DTOs\PartSearchResultDTO; -use App\Services\InfoProviderSystem\DTOs\SearchResultWithMetadataDTO; +use App\Services\InfoProviderSystem\DTOs\PartSearchResultsDTO; use App\Services\InfoProviderSystem\Providers\BatchInfoProviderInterface; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use Doctrine\ORM\EntityManagerInterface; @@ -94,7 +92,7 @@ final class BulkInfoProviderService $searchResults = $this->formatSearchResults($allResults); } - $partResults[] = new PartSearchResultDTO( + $partResults[] = new PartSearchResultsDTO( part: $part, searchResults: $searchResults, errors: [] @@ -148,7 +146,7 @@ final class BulkInfoProviderService if ($keyword && isset($providerResults[$keyword])) { foreach ($providerResults[$keyword] as $dto) { $batchResults[$part->getId()][] = new BulkSearchResultDTO( - baseDto: $dto, + searchResult: $dto, sourceField: $mapping->field, sourceKeyword: $keyword, localPart: $this->existingPartFinder->findFirstExisting($dto), @@ -207,7 +205,7 @@ final class BulkInfoProviderService foreach ($dtos as $dto) { $regularResults[$part->getId()][] = new BulkSearchResultDTO( - baseDto: $dto, + searchResult: $dto, sourceField: $mapping->field, sourceKeyword: $keyword, localPart: $this->existingPartFinder->findFirstExisting($dto), @@ -329,7 +327,7 @@ final class BulkInfoProviderService * Format and deduplicate search results. * * @param BulkSearchResultDTO[] $bulkResults Array of bulk search results - * @return SearchResultWithMetadataDTO[] Array of formatted search results with metadata + * @return BulkSearchResultDTO[] Array of formatted search results with metadata */ private function formatSearchResults(array $bulkResults): array { @@ -340,15 +338,10 @@ final class BulkInfoProviderService $seenKeys = []; foreach ($bulkResults as $result) { - $key = "{$result->getProviderKey()}|{$result->getProviderId()}"; + $key = "{$result->searchResult->provider_key}|{$result->searchResult->provider_id}"; if (!in_array($key, $seenKeys, true)) { $seenKeys[] = $key; - $uniqueResults[] = new SearchResultWithMetadataDTO( - searchResult: $result, - localPart: $result->localPart, - sourceField: $result->sourceField, - sourceKeyword: $result->sourceKeyword - ); + $uniqueResults[] = $result; } } @@ -358,46 +351,26 @@ final class BulkInfoProviderService /** * Prefetch detailed information for search results. * - * @param BulkSearchResponseDTO|array $searchResults Search results (supports both new DTO and legacy array format) + * @param BulkSearchResponseDTO $searchResults Search results (supports both new DTO and legacy array format) */ - public function prefetchDetailsForResults($searchResults): void + public function prefetchDetailsForResults(BulkSearchResponseDTO $searchResults): void { $prefetchCount = 0; // Handle both new DTO format and legacy array format for backwards compatibility - if ($searchResults instanceof BulkSearchResponseDTO) { - foreach ($searchResults->partResults as $partResult) { - foreach ($partResult->searchResults as $result) { - $dto = $result->searchResult; + foreach ($searchResults->partResults as $partResult) { + foreach ($partResult->searchResults as $result) { + $dto = $result->searchResult; - try { - $this->infoRetriever->getDetails($dto->getProviderKey(), $dto->getProviderId()); - $prefetchCount++; - } catch (\Exception $e) { - $this->logger->warning('Failed to prefetch details for provider part', [ - 'provider_key' => $dto->getProviderKey(), - 'provider_id' => $dto->getProviderId(), - 'error' => $e->getMessage() - ]); - } - } - } - } else { - // Legacy array format support - foreach ($searchResults as $partResult) { - foreach ($partResult['search_results'] as $result) { - $dto = $result['dto']; - - try { - $this->infoRetriever->getDetails($dto->getProviderKey(), $dto->getProviderId()); - $prefetchCount++; - } catch (\Exception $e) { - $this->logger->warning('Failed to prefetch details for provider part', [ - 'provider_key' => $dto->getProviderKey(), - 'provider_id' => $dto->getProviderId(), - 'error' => $e->getMessage() - ]); - } + try { + $this->infoRetriever->getDetails($dto->provider_key, $dto->provider_id); + $prefetchCount++; + } catch (\Exception $e) { + $this->logger->warning('Failed to prefetch details for provider part', [ + 'provider_key' => $dto->provider_key, + 'provider_id' => $dto->provider_id, + 'error' => $e->getMessage() + ]); } } } diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php index 3c2c2ad6..0e630d04 100644 --- a/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php @@ -22,43 +22,40 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; +use App\Entity\Parts\Part; +use Doctrine\ORM\EntityManagerInterface; + /** * Represents the complete response from a bulk info provider search operation. - * This DTO provides type safety and clear structure instead of complex arrays. + * It contains a list of PartSearchResultDTOs, one for each part searched. */ -readonly class BulkSearchResponseDTO +readonly class BulkSearchResponseDTO implements \ArrayAccess { /** - * @param PartSearchResultDTO[] $partResults Array of search results for each part + * @param PartSearchResultsDTO[] $partResults Array of search results for each part */ public function __construct( public array $partResults ) {} /** - * Create from legacy array format for backwards compatibility. - * @param array $data Array of part result arrays in legacy format + * Replaces the search results for a specific part, and returns a new instance. + * @param Part|int $part + * @param PartSearchResultsDTO $new_results + * @return BulkSearchResponseDTO */ - public static function fromArray(array $data): self + public function replaceResultsForPart(Part|int $part, PartSearchResultsDTO $new_results): self { - $partResults = []; - foreach ($data as $partData) { - $partResults[] = PartSearchResultDTO::fromArray($partData); + $array = $this->partResults; + foreach ($array as $index => $partResult) { + if (($part instanceof Part && $partResult->part->getId() === $part->getId()) || + ($partResult->part->getId() === $part)) { + $array[$index] = $new_results; + break; + } } - return new self($partResults); - } - - /** - * Convert to legacy array format for backwards compatibility. - */ - public function toArray(): array - { - $result = []; - foreach ($this->partResults as $partResult) { - $result[] = $partResult->toArray(); - } - return $result; + return new self($array); } /** @@ -88,7 +85,7 @@ readonly class BulkSearchResponseDTO /** * Get all parts that have search results. - * @return PartSearchResultDTO[] + * @return PartSearchResultsDTO[] */ public function getPartsWithResults(): array { @@ -97,7 +94,7 @@ readonly class BulkSearchResponseDTO /** * Get all parts that have errors. - * @return PartSearchResultDTO[] + * @return PartSearchResultsDTO[] */ public function getPartsWithErrors(): array { @@ -119,4 +116,104 @@ readonly class BulkSearchResponseDTO { return count($this->getPartsWithResults()); } -} \ No newline at end of file + + /** + * Merge multiple BulkSearchResponseDTO instances into one. + * @param BulkSearchResponseDTO ...$responses + * @return BulkSearchResponseDTO + */ + public static function merge(BulkSearchResponseDTO ...$responses): BulkSearchResponseDTO + { + $mergedResults = []; + foreach ($responses as $response) { + foreach ($response->partResults as $partResult) { + $mergedResults[] = $partResult; + } + } + return new BulkSearchResponseDTO($mergedResults); + } + + /** + * Convert this DTO to a serializable representation suitable for storage in the database + * @return array + */ + public function toSerializableRepresentation(): array + { + $serialized = []; + + foreach ($this->partResults as $partResult) { + $partData = [ + 'part_id' => $partResult->part->getId(), + 'search_results' => [], + 'errors' => $partResult->errors ?? [] + ]; + + foreach ($partResult->searchResults as $result) { + $partData['search_results'][] = [ + 'dto' => $result->searchResult->toNormalizedSearchResultArray(), + 'source_field' => $result->sourceField ?? null, + 'source_keyword' => $result->sourceKeyword ?? null, + 'localPart' => $result->localPart?->getId(), + 'priority' => $result->priority + ]; + } + + $serialized[] = $partData; + } + + return $serialized; + } + + /** + * Creates a BulkSearchResponseDTO from a serializable representation. + * @param array $data + * @param EntityManagerInterface $entityManager + * @return BulkSearchResponseDTO + * @throws \Doctrine\ORM\Exception\ORMException + */ + public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO + { + $partResults = []; + foreach ($data as $partData) { + $partResults[] = new PartSearchResultsDTO( + part: $entityManager->getReference(Part::class, $partData['part_id']), + searchResults: array_map(fn($result) => new BulkSearchResultDTO( + searchResult: SearchResultDTO::fromNormalizedSearchResultArray($result['dto']), + sourceField: $result['source_field'] ?? null, + sourceKeyword: $result['source_keyword'] ?? null, + localPart: isset($result['localPart']) ? $entityManager->getReference(Part::class, $result['localPart']) : null, + priority: $result['priority'] ?? null + ), $partData['search_results'] ?? []), + errors: $partData['errors'] ?? [] + ); + } + + return new BulkSearchResponseDTO($partResults); + } + + public function offsetExists(mixed $offset): bool + { + if (!is_int($offset)) { + throw new \InvalidArgumentException("Offset must be an integer."); + } + return isset($this->partResults[$offset]); + } + + public function offsetGet(mixed $offset): ?PartSearchResultsDTO + { + if (!is_int($offset)) { + throw new \InvalidArgumentException("Offset must be an integer."); + } + return $this->partResults[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \LogicException("BulkSearchResponseDTO is immutable."); + } + + public function offsetUnset(mixed $offset): void + { + throw new \LogicException('BulkSearchResponseDTO is immutable.'); + } +} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchResultDTO.php index 7beb4291..04c41a64 100644 --- a/src/Services/InfoProviderSystem/DTOs/BulkSearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/BulkSearchResultDTO.php @@ -22,18 +22,16 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\DTOs; -use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; /** - * Represents a search result from bulk search with additional context information. - * Uses composition instead of inheritance for better maintainability. + * Represents a search result from bulk search with additional context information, like how the part was found. */ readonly class BulkSearchResultDTO { public function __construct( /** The base search result DTO containing provider data */ - public SearchResultDTO $baseDto, + public SearchResultDTO $searchResult, /** The field that was used to find this result */ public ?string $sourceField = null, /** The actual keyword that was searched for */ @@ -43,97 +41,4 @@ readonly class BulkSearchResultDTO /** Priority for this search result */ public int $priority = 1 ) {} - - // Delegation methods for SearchResultDTO properties - public function getProviderKey(): string - { - return $this->baseDto->provider_key; - } - - public function getProviderId(): string - { - return $this->baseDto->provider_id; - } - - public function getName(): string - { - return $this->baseDto->name; - } - - public function getDescription(): string - { - return $this->baseDto->description; - } - - public function getCategory(): ?string - { - return $this->baseDto->category; - } - - public function getManufacturer(): ?string - { - return $this->baseDto->manufacturer; - } - - public function getMpn(): ?string - { - return $this->baseDto->mpn; - } - - public function getPreviewImageUrl(): ?string - { - return $this->baseDto->preview_image_url; - } - - public function getPreviewImageFile(): ?FileDTO - { - return $this->baseDto->preview_image_file; - } - - public function getManufacturingStatus(): ?ManufacturingStatus - { - return $this->baseDto->manufacturing_status; - } - - public function getProviderUrl(): ?string - { - return $this->baseDto->provider_url; - } - - public function getFootprint(): ?string - { - return $this->baseDto->footprint; - } - - // Backwards compatibility properties for legacy code - public function __get(string $name): mixed - { - return match ($name) { - 'provider_key' => $this->baseDto->provider_key, - 'provider_id' => $this->baseDto->provider_id, - 'name' => $this->baseDto->name, - 'description' => $this->baseDto->description, - 'category' => $this->baseDto->category, - 'manufacturer' => $this->baseDto->manufacturer, - 'mpn' => $this->baseDto->mpn, - 'preview_image_url' => $this->baseDto->preview_image_url, - 'preview_image_file' => $this->baseDto->preview_image_file, - 'manufacturing_status' => $this->baseDto->manufacturing_status, - 'provider_url' => $this->baseDto->provider_url, - 'footprint' => $this->baseDto->footprint, - default => throw new \InvalidArgumentException("Property '{$name}' does not exist") - }; - } - - /** - * Magic isset method for backwards compatibility. - */ - public function __isset(string $name): bool - { - return in_array($name, [ - 'provider_key', 'provider_id', 'name', 'description', 'category', - 'manufacturer', 'mpn', 'preview_image_url', 'preview_image_file', - 'manufacturing_status', 'provider_url', 'footprint' - ], true); - } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 9f365f1e..41d50510 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -70,4 +70,4 @@ class PartDetailDTO extends SearchResultDTO footprint: $footprint, ); } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/DTOs/PartSearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/PartSearchResultsDTO.php similarity index 61% rename from src/Services/InfoProviderSystem/DTOs/PartSearchResultDTO.php rename to src/Services/InfoProviderSystem/DTOs/PartSearchResultsDTO.php index f516ec1e..ae414a04 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartSearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartSearchResultsDTO.php @@ -28,11 +28,11 @@ use App\Entity\Parts\Part; * Represents the search results for a single part from bulk info provider search. * This DTO provides type safety and clear structure for part search results. */ -readonly class PartSearchResultDTO +readonly class PartSearchResultsDTO { /** * @param Part $part The part that was searched for - * @param SearchResultWithMetadataDTO[] $searchResults Array of search results found for this part + * @param BulkSearchResultDTO[] $searchResults Array of search results found for this part * @param string[] $errors Array of error messages encountered during search */ public function __construct( @@ -41,42 +41,6 @@ readonly class PartSearchResultDTO public array $errors = [] ) {} - /** - * Create from legacy array format for backwards compatibility. - * @param array{part: Part, search_results: array, errors: string[]} $data - */ - public static function fromArray(array $data): self - { - $searchResults = []; - foreach ($data['search_results'] as $result) { - $searchResults[] = SearchResultWithMetadataDTO::fromArray($result); - } - - return new self( - part: $data['part'], - searchResults: $searchResults, - errors: $data['errors'] ?? [] - ); - } - - /** - * Convert to legacy array format for backwards compatibility. - * @return array{part: Part, search_results: array, errors: string[]} - */ - public function toArray(): array - { - $searchResults = []; - foreach ($this->searchResults as $result) { - $searchResults[] = $result->toArray(); - } - - return [ - 'part' => $this->part, - 'search_results' => $searchResults, - 'errors' => $this->errors, - ]; - } - /** * Check if this part has any search results. */ @@ -101,14 +65,19 @@ readonly class PartSearchResultDTO return count($this->searchResults); } + public function getErrorCount(): int + { + return count($this->errors); + } + /** * Get search results sorted by priority (ascending). - * @return SearchResultWithMetadataDTO[] + * @return BulkSearchResultDTO[] */ public function getResultsSortedByPriority(): array { $results = $this->searchResults; - usort($results, fn($a, $b) => $a->getPriority() <=> $b->getPriority()); + usort($results, static fn(BulkSearchResultDTO $a, BulkSearchResultDTO $b) => $a->priority <=> $b->priority); return $results; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 28943702..a70b2486 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -59,8 +59,8 @@ class SearchResultDTO public readonly ?string $provider_url = null, /** @var string|null A footprint representation of the providers page */ public readonly ?string $footprint = null, - ) { - + ) + { if ($preview_image_url !== null) { //Utilize the escaping mechanism of FileDTO to ensure that the preview image URL is correctly encoded //See issue #521: https://github.com/Part-DB/Part-DB-server/issues/521 @@ -71,4 +71,47 @@ class SearchResultDTO $this->preview_image_url = null; } } -} \ No newline at end of file + + /** + * This method creates a normalized array representation of the DTO. + * @return array + */ + public function toNormalizedSearchResultArray(): array + { + return [ + 'provider_key' => $this->provider_key, + 'provider_id' => $this->provider_id, + 'name' => $this->name, + 'description' => $this->description, + 'category' => $this->category, + 'manufacturer' => $this->manufacturer, + 'mpn' => $this->mpn, + 'preview_image_url' => $this->preview_image_url, + 'manufacturing_status' => $this->manufacturing_status?->value, + 'provider_url' => $this->provider_url, + 'footprint' => $this->footprint, + ]; + } + + /** + * Creates a SearchResultDTO from a normalized array representation. + * @param array $data + * @return self + */ + public static function fromNormalizedSearchResultArray(array $data): self + { + return new self( + provider_key: $data['provider_key'], + provider_id: $data['provider_id'], + name: $data['name'], + description: $data['description'], + category: $data['category'] ?? null, + manufacturer: $data['manufacturer'] ?? null, + mpn: $data['mpn'] ?? null, + preview_image_url: $data['preview_image_url'] ?? null, + manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null, + provider_url: $data['provider_url'] ?? null, + footprint: $data['footprint'] ?? null, + ); + } +} diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultWithMetadataDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultWithMetadataDTO.php deleted file mode 100644 index 555e3b61..00000000 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultWithMetadataDTO.php +++ /dev/null @@ -1,95 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Services\InfoProviderSystem\DTOs; - -use App\Entity\Parts\Part; - -/** - * Represents a search result with additional metadata about how it was found. - * This DTO encapsulates both the search result data and the context of the search. - */ -readonly class SearchResultWithMetadataDTO -{ - public function __construct( - /** The search result DTO containing part information from the provider */ - public BulkSearchResultDTO $searchResult, - /** Local part that matches this search result, if any */ - public ?Part $localPart = null, - /** The field that was used to find this result (e.g., 'mpn', 'name') */ - public ?string $sourceField = null, - /** The actual keyword/value that was searched for */ - public ?string $sourceKeyword = null - ) {} - - /** - * Create from legacy array format for backwards compatibility. - * @param array{dto: BulkSearchResultDTO, localPart: ?Part, source_field: string, source_keyword: string} $data - */ - public static function fromArray(array $data): self - { - return new self( - searchResult: $data['dto'], - localPart: $data['localPart'] ?? null, - sourceField: $data['source_field'] ?? null, - sourceKeyword: $data['source_keyword'] ?? null - ); - } - - /** - * Convert to legacy array format for backwards compatibility. - * @return array{dto: BulkSearchResultDTO, localPart: ?Part, source_field: ?string, source_keyword: ?string} - */ - public function toArray(): array - { - return [ - 'dto' => $this->searchResult, - 'localPart' => $this->localPart, - 'source_field' => $this->sourceField, - 'source_keyword' => $this->sourceKeyword, - ]; - } - - /** - * Get the priority of this search result. - */ - public function getPriority(): int - { - return $this->searchResult->priority; - } - - /** - * Get the provider key from the search result. - */ - public function getProviderKey(): string - { - return $this->searchResult->getProviderKey(); - } - - /** - * Get the provider ID from the search result. - */ - public function getProviderId(): string - { - return $this->searchResult->getProviderId(); - } -} \ No newline at end of file diff --git a/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php new file mode 100644 index 00000000..bc33de3d --- /dev/null +++ b/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php @@ -0,0 +1,95 @@ +. + */ + +namespace App\Tests\Services\InfoProviderSystem\DTOs; + +use App\Doctrine\Types\BulkSearchResponseDTOType; +use App\Entity\Parts\Part; +use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; +use App\Services\InfoProviderSystem\DTOs\BulkSearchResultDTO; +use App\Services\InfoProviderSystem\DTOs\PartSearchResultsDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class BulkSearchResponseDTOTest extends KernelTestCase +{ + + private EntityManagerInterface $entityManager; + + private BulkSearchResponseDTO $dummyEmpty; + private BulkSearchResponseDTO $dummy; + + protected function setUp(): void + { + self::bootKernel(); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $this->dummyEmpty = new BulkSearchResponseDTO(partResults: []); + $this->dummy = new BulkSearchResponseDTO(partResults: [ + new PartSearchResultsDTO( + part: $this->entityManager->find(Part::class, 1), + searchResults: [ + new BulkSearchResultDTO( + searchResult: new SearchResultDTO(provider_key: "dummy", provider_id: "1234", name: "Test Part", description: "A part for testing"), + sourceField: "mpn", sourceKeyword: "1234", priority: 1 + ), + new BulkSearchResultDTO( + searchResult: new SearchResultDTO(provider_key: "test", provider_id: "test", name: "Test Part2", description: "A part for testing"), + sourceField: "name", sourceKeyword: "1234", + localPart: $this->entityManager->find(Part::class, 2), priority: 2, + ), + ], + errors: ['Error 1'] + ) + ]); + } + + public function testSerializationBackAndForthEmpty(): void + { + $serialized = $this->dummyEmpty->toSerializableRepresentation(); + //Ensure that it is json_encodable + $json = json_encode($serialized, JSON_THROW_ON_ERROR); + $this->assertJson($json); + $deserialized = BulkSearchResponseDTO::fromSerializableRepresentation(json_decode($json), $this->entityManager); + + $this->assertEquals($this->dummyEmpty, $deserialized); + } + + public function testSerializationBackAndForth(): void + { + $serialized = $this->dummy->toSerializableRepresentation(); + //Ensure that it is json_encodable + $this->assertJson(json_encode($serialized, JSON_THROW_ON_ERROR)); + $deserialized = BulkSearchResponseDTO::fromSerializableRepresentation($serialized, $this->entityManager); + + $this->assertEquals($this->dummy, $deserialized); + } + + public function testToSerializableRepresentation(): void + { + + } + + public function testFromSerializableRepresentation(): void + { + } +}