Doing refactoring to remove remains of arrays

This commit is contained in:
Jan Böhmer 2025-09-21 14:24:34 +02:00
parent 98b62cc81e
commit 27a18bdc1e
10 changed files with 341 additions and 443 deletions

View file

@ -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) {

View file

@ -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;
}
}

View file

@ -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()
]);
}
}
}

View file

@ -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());
}
/**
* 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.');
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}
/**
* 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,
);
}
}

View file

@ -1,95 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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();
}
}

View file

@ -0,0 +1,95 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
{
}
}