diff --git a/src/DataTables/Adapters/TwoStepORMAdapter.php b/src/DataTables/Adapters/TwoStepORMAdapter.php index 51315c32..c584e188 100644 --- a/src/DataTables/Adapters/TwoStepORMAdapter.php +++ b/src/DataTables/Adapters/TwoStepORMAdapter.php @@ -54,6 +54,8 @@ class TwoStepORMAdapter extends ORMAdapter private \Closure|null $query_modifier = null; + private \Closure|null $dto_hydrator = null; + public function __construct(?ManagerRegistry $registry = null) { parent::__construct($registry); @@ -82,6 +84,10 @@ class TwoStepORMAdapter extends ORMAdapter $resolver->setDefault('query_modifier', null); $resolver->setAllowedTypes('query_modifier', ['null', \Closure::class]); + //Add the possibility to use a custom DTO hydrator instead of entity hydration + $resolver->setDefault('dto_hydrator', null); + $resolver->setAllowedTypes('dto_hydrator', ['null', \Closure::class]); + } protected function afterConfiguration(array $options): void @@ -90,6 +96,7 @@ class TwoStepORMAdapter extends ORMAdapter $this->detailQueryCallable = $options['detail_query']; $this->use_simple_total = $options['simple_total_query']; $this->query_modifier = $options['query_modifier']; + $this->dto_hydrator = $options['dto_hydrator']; } protected function prepareQuery(AdapterQuery $query): void @@ -189,9 +196,19 @@ class TwoStepORMAdapter extends ORMAdapter $detail_query = $detail_qb->getQuery(); - //We pass the results of the detail query to the datatable for view rendering - foreach ($detail_query->getResult() as $item) { - yield $item; + // If a DTO hydrator is configured, use array hydration and build DTOs + if ($this->dto_hydrator !== null) { + $arrayResults = $detail_query->getArrayResult(); + $dtos = $this->dto_hydrator->__invoke($arrayResults); + foreach ($dtos as $dto) { + yield $dto; + } + } else { + // Original behavior: hydrate as entities + //We pass the results of the detail query to the datatable for view rendering + foreach ($detail_query->getResult() as $item) { + yield $item; + } } } diff --git a/src/DataTables/DTO/PartDTO.php b/src/DataTables/DTO/PartDTO.php new file mode 100644 index 00000000..09ce111b --- /dev/null +++ b/src/DataTables/DTO/PartDTO.php @@ -0,0 +1,409 @@ +. + */ + +namespace App\DataTables\DTO; + +use App\Entity\Parts\ManufacturingStatus; + +/** + * Lightweight DTO for PartsDataTable containing only data needed for table rendering. + * This avoids loading full Part entities with all relationships, significantly improving performance. + * + * The DTO is populated directly from optimized query results, selecting only required fields. + */ +class PartDTO +{ + /** @var PartLotDTO[] */ + private array $partLots = []; + + /** @var int[] */ + private array $attachmentIds = []; + + /** @var array */ + private array $projects = []; + + public function __construct( + // Core Part fields + public readonly int $id, + public readonly string $name, + public readonly ?string $ipn, + public readonly ?string $description, + public readonly float $minamount, + public readonly ?string $manufacturer_product_number, + public readonly ?float $mass, + public readonly ?string $gtin, + public readonly string $tags, + public readonly bool $favorite, + public readonly bool $needs_review, + public readonly ?\DateTimeInterface $addedDate, + public readonly ?\DateTimeInterface $lastModified, + public readonly ?ManufacturingStatus $manufacturing_status, + + // Related entity IDs and names (pre-joined for display) + public readonly ?int $category_id, + public readonly ?string $category_name, + public readonly ?int $footprint_id, + public readonly ?string $footprint_name, + public readonly ?int $manufacturer_id, + public readonly ?string $manufacturer_name, + public readonly ?int $partUnit_id, + public readonly ?string $partUnit_name, + public readonly ?string $partUnit_unit, + public readonly ?int $partCustomState_id, + public readonly ?string $partCustomState_name, + public readonly ?int $master_picture_attachment_id, + public readonly ?string $master_picture_attachment_filename, + public readonly ?string $master_picture_attachment_name, + public readonly ?int $footprint_attachment_id, + public readonly ?int $builtProject_id, + public readonly ?string $builtProject_name, + + // Computed/aggregated fields + public readonly float $amountSum, + public readonly float $expiredAmountSum, + public readonly bool $hasUnknownAmount, + ) { + } + + // Compatibility methods that match Part entity interface + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function isFavorite(): bool + { + return $this->favorite; + } + + public function isNeedsReview(): bool + { + return $this->needs_review; + } + + public function isNotEnoughInstock(): bool + { + return $this->amountSum < $this->minamount; + } + + public function isAmountUnknown(): bool + { + return $this->hasUnknownAmount; + } + + public function getAmountSum(): float + { + return $this->amountSum; + } + + public function getExpiredAmountSum(): float + { + return $this->expiredAmountSum; + } + + /** + * Get built project as a simple object compatible with renderer needs + */ + public function getBuiltProject(): ?object + { + if ($this->builtProject_id === null) { + return null; + } + + return new class($this->builtProject_id, $this->builtProject_name) { + public function __construct( + private readonly int $id, + private readonly ?string $name, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + }; + } + + /** + * Get part unit as a simple object compatible with renderer needs + */ + public function getPartUnit(): ?object + { + if ($this->partUnit_id === null) { + return null; + } + + return new class($this->partUnit_id, $this->partUnit_name, $this->partUnit_unit) { + public function __construct( + private readonly int $id, + private readonly ?string $name, + private readonly ?string $unit, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getUnit(): ?string + { + return $this->unit; + } + }; + } + + /** + * Get category as a simple object compatible with renderer needs + */ + public function getCategory(): ?object + { + if ($this->category_id === null) { + return null; + } + + return new class($this->category_id, $this->category_name) { + public function __construct( + private readonly int $id, + private readonly ?string $name, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + }; + } + + /** + * Get footprint as a simple object compatible with renderer needs + */ + public function getFootprint(): ?object + { + if ($this->footprint_id === null) { + return null; + } + + return new class($this->footprint_id, $this->footprint_name) { + public function __construct( + private readonly int $id, + private readonly ?string $name, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + }; + } + + /** + * Get manufacturer as a simple object compatible with renderer needs + */ + public function getManufacturer(): ?object + { + if ($this->manufacturer_id === null) { + return null; + } + + return new class($this->manufacturer_id, $this->manufacturer_name) { + public function __construct( + private readonly int $id, + private readonly ?string $name, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + }; + } + + /** + * Get part custom state as a simple object compatible with renderer needs + */ + public function getPartCustomState(): ?object + { + if ($this->partCustomState_id === null) { + return null; + } + + return new class($this->partCustomState_id, $this->partCustomState_name) { + public function __construct( + private readonly int $id, + private readonly ?string $name, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + }; + } + + /** + * Get master picture attachment as a simple object for rendering + */ + public function getMasterPictureAttachment(): ?object + { + if ($this->master_picture_attachment_id === null) { + return null; + } + + return new class($this->master_picture_attachment_id, $this->master_picture_attachment_name, $this->master_picture_attachment_filename) { + public function __construct( + private readonly int $id, + private readonly ?string $name, + private readonly ?string $filename, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getFilename(): ?string + { + return $this->filename; + } + }; + } + + /** + * Get footprint's master picture attachment + */ + public function getFootprintAttachment(): ?object + { + if ($this->footprint_attachment_id === null) { + return null; + } + + return new class($this->footprint_attachment_id) { + public function __construct( + private readonly int $id, + ) { + } + + public function getId(): int + { + return $this->id; + } + }; + } + + /** + * @return PartLotDTO[] + */ + public function getPartLots(): array + { + return $this->partLots; + } + + /** + * @param PartLotDTO[] $partLots + */ + public function setPartLots(array $partLots): void + { + $this->partLots = $partLots; + } + + /** + * Get attachment IDs for rendering + * @return int[] + */ + public function getAttachments(): array + { + return $this->attachmentIds; + } + + /** + * @param int[] $attachmentIds + */ + public function setAttachments(array $attachmentIds): void + { + $this->attachmentIds = $attachmentIds; + } + + /** + * Get projects where this part is used + * @return array + */ + public function getProjects(): array + { + return $this->projects; + } + + /** + * @param array $projects + */ + public function setProjects(array $projects): void + { + $this->projects = $projects; + } +} diff --git a/src/DataTables/DTO/PartDTOHydrator.php b/src/DataTables/DTO/PartDTOHydrator.php new file mode 100644 index 00000000..e7d4a57b --- /dev/null +++ b/src/DataTables/DTO/PartDTOHydrator.php @@ -0,0 +1,163 @@ +. + */ + +namespace App\DataTables\DTO; + +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query; + +/** + * Builds PartDTO objects from database query results. + * Handles the hydration of lightweight DTOs instead of full Part entities. + */ +class PartDTOHydrator +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + ) { + } + + /** + * Build PartDTO objects from a query result set. + * Expects results from the optimized detail query that selects specific fields. + * + * @param array $queryResults Array of results from the detail query + * @return PartDTO[] + */ + public function hydrateFromQueryResults(array $queryResults): array + { + $dtos = []; + $partLotsGrouped = []; + $attachmentsGrouped = []; + $projectsGrouped = []; + + // First pass: Group related data by part ID + foreach ($queryResults as $row) { + $partId = $row['id']; + + // Group part lots by part ID + if (isset($row['partLot_id']) && $row['partLot_id'] !== null) { + if (!isset($partLotsGrouped[$partId])) { + $partLotsGrouped[$partId] = []; + } + $lotKey = $row['partLot_id']; + if (!isset($partLotsGrouped[$partId][$lotKey])) { + $partLotsGrouped[$partId][$lotKey] = new PartLotDTO( + id: $row['partLot_id'], + storage_location_id: $row['storageLocation_id'] ?? null, + storage_location_name: $row['storageLocation_name'] ?? null, + storage_location_fullPath: $row['storageLocation_fullPath'] ?? null, + ); + } + } + + // Group attachments by part ID + if (isset($row['attachment_id']) && $row['attachment_id'] !== null) { + if (!isset($attachmentsGrouped[$partId])) { + $attachmentsGrouped[$partId] = []; + } + $attachmentsGrouped[$partId][$row['attachment_id']] = $row['attachment_id']; + } + + // Group projects by part ID + if (isset($row['project_id']) && $row['project_id'] !== null) { + if (!isset($projectsGrouped[$partId])) { + $projectsGrouped[$partId] = []; + } + $projectKey = $row['project_id']; + if (!isset($projectsGrouped[$partId][$projectKey])) { + $projectsGrouped[$partId][$projectKey] = [ + 'id' => $row['project_id'], + 'name' => $row['project_name'] ?? '', + ]; + } + } + } + + // Second pass: Create DTOs (one per part, using first row's data) + $processedParts = []; + foreach ($queryResults as $row) { + $partId = $row['id']; + + // Skip if we've already processed this part + if (isset($processedParts[$partId])) { + continue; + } + $processedParts[$partId] = true; + + $dto = new PartDTO( + id: $row['id'], + name: $row['name'], + ipn: $row['ipn'] ?? null, + description: $row['description'] ?? null, + minamount: $row['minamount'] ?? 0.0, + manufacturer_product_number: $row['manufacturer_product_number'] ?? null, + mass: $row['mass'] ?? null, + gtin: $row['gtin'] ?? null, + tags: $row['tags'] ?? '', + favorite: $row['favorite'] ?? false, + needs_review: $row['needs_review'] ?? false, + addedDate: $row['addedDate'] ?? null, + lastModified: $row['lastModified'] ?? null, + manufacturing_status: $row['manufacturing_status'] ?? null, + category_id: $row['category_id'] ?? null, + category_name: $row['category_name'] ?? null, + footprint_id: $row['footprint_id'] ?? null, + footprint_name: $row['footprint_name'] ?? null, + manufacturer_id: $row['manufacturer_id'] ?? null, + manufacturer_name: $row['manufacturer_name'] ?? null, + partUnit_id: $row['partUnit_id'] ?? null, + partUnit_name: $row['partUnit_name'] ?? null, + partUnit_unit: $row['partUnit_unit'] ?? null, + partCustomState_id: $row['partCustomState_id'] ?? null, + partCustomState_name: $row['partCustomState_name'] ?? null, + master_picture_attachment_id: $row['master_picture_attachment_id'] ?? null, + master_picture_attachment_filename: $row['master_picture_attachment_filename'] ?? null, + master_picture_attachment_name: $row['master_picture_attachment_name'] ?? null, + footprint_attachment_id: $row['footprint_attachment_id'] ?? null, + builtProject_id: $row['builtProject_id'] ?? null, + builtProject_name: $row['builtProject_name'] ?? null, + amountSum: $row['amountSum'] ?? 0.0, + expiredAmountSum: $row['expiredAmountSum'] ?? 0.0, + hasUnknownAmount: $row['hasUnknownAmount'] ?? false, + ); + + // Attach grouped data + if (isset($partLotsGrouped[$partId])) { + $dto->setPartLots(array_values($partLotsGrouped[$partId])); + } + if (isset($attachmentsGrouped[$partId])) { + $dto->setAttachments(array_values($attachmentsGrouped[$partId])); + } + if (isset($projectsGrouped[$partId])) { + $dto->setProjects(array_values($projectsGrouped[$partId])); + } + + $dtos[] = $dto; + } + + return $dtos; + } +} diff --git a/src/DataTables/DTO/PartLotDTO.php b/src/DataTables/DTO/PartLotDTO.php new file mode 100644 index 00000000..913e820a --- /dev/null +++ b/src/DataTables/DTO/PartLotDTO.php @@ -0,0 +1,71 @@ +. + */ + +namespace App\DataTables\DTO; + +/** + * Lightweight data structure representing a Part Lot for table display. + * Contains only essential fields needed for rendering storage locations. + */ +readonly class PartLotDTO +{ + public function __construct( + public int $id, + public ?int $storage_location_id, + public ?string $storage_location_name, + public ?string $storage_location_fullPath, + ) { + } + + public function getStorageLocation(): ?object + { + if ($this->storage_location_id === null) { + return null; + } + + // Return a simple object with needed methods for rendering + return new class($this->storage_location_id, $this->storage_location_name, $this->storage_location_fullPath) { + public function __construct( + private readonly int $id, + private readonly ?string $name, + private readonly ?string $fullPath, + ) { + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getFullPath(): ?string + { + return $this->fullPath; + } + }; + } +} diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php index c33c3a82..708429e7 100644 --- a/src/DataTables/Helpers/PartDataTableHelper.php +++ b/src/DataTables/Helpers/PartDataTableHelper.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\DataTables\Helpers; +use App\DataTables\DTO\PartDTO; use App\Entity\Parts\StorageLocation; use App\Entity\ProjectSystem\Project; use App\Entity\Attachments\Attachment; @@ -31,6 +32,7 @@ use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\PartPreviewGenerator; use App\Services\EntityURLGenerator; use App\Services\Formatters\AmountFormatter; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -44,10 +46,11 @@ class PartDataTableHelper private readonly EntityURLGenerator $entityURLGenerator, private readonly TranslatorInterface $translator, private readonly AmountFormatter $amountFormatter, + private readonly EntityManagerInterface $entityManager, ) { } - public function renderName(Part $context): string + public function renderName($context): string { $icon = ''; @@ -60,22 +63,62 @@ class PartDataTableHelper $icon = sprintf('', $this->translator->trans('part.needs_review.badge')); } - if ($context->getBuiltProject() instanceof Project) { + if ($context->getBuiltProject() !== null) { $icon = sprintf('', $this->translator->trans('part.info.projectBuildPart.hint').': '.$context->getBuiltProject()->getName()); } + // For DTO, create a Part proxy for URL generation + $partForUrl = $context; + if ($context instanceof PartDTO) { + $partForUrl = $this->entityManager->getReference(Part::class, $context->getId()); + } return sprintf( '%s%s', - $this->entityURLGenerator->infoURL($context), + $this->entityURLGenerator->infoURL($partForUrl), $icon, htmlspecialchars($context->getName()) ); } - public function renderPicture(Part $context): string + public function renderPicture($context): string { + // For DTO, we already have the attachment info, so we can create a lightweight attachment object + if ($context instanceof PartDTO) { + $preview_attachment = null; + + // First check if part has a master picture attachment + if ($context->master_picture_attachment_id !== null) { + $preview_attachment = $this->entityManager->getReference(Attachment::class, $context->master_picture_attachment_id); + } + // Otherwise check if footprint has a master picture attachment + elseif ($context->footprint_attachment_id !== null) { + $preview_attachment = $this->entityManager->getReference(Attachment::class, $context->footprint_attachment_id); + } + + if (!$preview_attachment instanceof Attachment) { + return ''; + } + + // For DTO we have the name and filename pre-loaded, but we need to access them from the full attachment + // Since we're using getReference, we'll get a proxy that will load data on access + $title = htmlspecialchars($context->master_picture_attachment_name ?? ''); + if ($context->master_picture_attachment_filename) { + $title .= ' ('.htmlspecialchars($context->master_picture_attachment_filename).')'; + } + + return sprintf( + '%s', + 'Part image', + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment), + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'), + 'hoverpic part-table-image', + $title + ); + } + + // Original behavior for Part entities $preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context); if (!$preview_attachment instanceof Attachment) { return ''; @@ -96,26 +139,40 @@ class PartDataTableHelper ); } - public function renderStorageLocations(Part $context): string + public function renderStorageLocations($context): string { $tmp = []; + + // For DTO, part lots are already PartLotDTO objects foreach ($context->getPartLots() as $lot) { //Ignore lots without storelocation - if (!$lot->getStorageLocation() instanceof StorageLocation) { + $storageLocation = $lot->getStorageLocation(); + if ($storageLocation === null) { continue; } + + // For DTO, we need to create a StorageLocation reference for URL generation + $storageLocationForUrl = $storageLocation; + if (!($storageLocation instanceof StorageLocation)) { + // The lot DTO returns a simple object, we need a proper reference + $storageLocationForUrl = $this->entityManager->getReference( + StorageLocation::class, + $storageLocation->getId() + ); + } + $tmp[] = sprintf( '%s', - $this->entityURLGenerator->listPartsURL($lot->getStorageLocation()), - htmlspecialchars($lot->getStorageLocation()->getFullPath()), - htmlspecialchars($lot->getStorageLocation()->getName()) + $this->entityURLGenerator->listPartsURL($storageLocationForUrl), + htmlspecialchars($storageLocation->getFullPath() ?? ''), + htmlspecialchars($storageLocation->getName() ?? '') ); } return implode('
', $tmp); } - public function renderAmount(Part $context): string + public function renderAmount($context): string { $amount = $context->getAmountSum(); $expiredAmount = $context->getExpiredAmountSum(); diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index fbc5211d..97415e5d 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -34,6 +34,8 @@ use App\DataTables\Column\RowClassColumn; use App\DataTables\Column\SelectColumn; use App\DataTables\Column\SIUnitNumberColumn; use App\DataTables\Column\TagsColumn; +use App\DataTables\DTO\PartDTO; +use App\DataTables\DTO\PartDTOHydrator; use App\DataTables\Filters\PartFilter; use App\DataTables\Filters\PartSearchFilter; use App\DataTables\Helpers\ColumnSortHelper; @@ -47,6 +49,7 @@ use App\Services\EntityURLGenerator; use App\Services\Formatters\AmountFormatter; use App\Settings\BehaviorSettings\TableSettings; use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; use Omines\DataTablesBundle\Column\TextColumn; @@ -68,6 +71,8 @@ final class PartsDataTable implements DataTableTypeInterface private readonly Security $security, private readonly ColumnSortHelper $csh, private readonly TableSettings $tableSettings, + private readonly PartDTOHydrator $partDTOHydrator, + private readonly EntityManagerInterface $entityManager, ) { } @@ -91,7 +96,7 @@ final class PartsDataTable implements DataTableTypeInterface $this->csh //Color the table rows depending on the review and favorite status ->add('row_color', RowClassColumn::class, [ - 'render' => function ($value, Part $context): string { + 'render' => function ($value, $context): string { if ($context->isNeedsReview()) { return 'table-secondary'; } @@ -106,11 +111,11 @@ final class PartsDataTable implements DataTableTypeInterface ->add('picture', TextColumn::class, [ 'label' => '', 'className' => 'no-colvis', - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context), + 'render' => fn($value, $context) => $this->partDataTableHelper->renderPicture($context), ], visibility_configurable: false) ->add('name', TextColumn::class, [ 'label' => $this->translator->trans('part.table.name'), - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context), + 'render' => fn($value, $context) => $this->partDataTableHelper->renderName($context), 'orderField' => 'NATSORT(part.name)' ]) ->add('id', TextColumn::class, [ @@ -142,17 +147,17 @@ final class PartsDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('part.table.storeLocations'), //We need to use a aggregate function to get the first store location, as we have a one-to-many relation 'orderField' => 'NATSORT(MIN(_storelocations.name))', - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context), + 'render' => fn($value, $context) => $this->partDataTableHelper->renderStorageLocations($context), ], alias: 'storage_location') ->add('amount', TextColumn::class, [ 'label' => $this->translator->trans('part.table.amount'), - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context), + 'render' => fn($value, $context) => $this->partDataTableHelper->renderAmount($context), 'orderField' => 'amountSum' ]) ->add('minamount', TextColumn::class, [ 'label' => $this->translator->trans('part.table.minamount'), - 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format( + 'render' => fn($value, $context): string => htmlspecialchars($this->amountFormatter->format( $value, $context->getPartUnit() )), @@ -160,7 +165,7 @@ final class PartsDataTable implements DataTableTypeInterface ->add('partUnit', TextColumn::class, [ 'label' => $this->translator->trans('part.table.partUnit'), 'orderField' => 'NATSORT(_partUnit.name)', - 'render' => function ($value, Part $context): string { + 'render' => function ($value, $context): string { $partUnit = $context->getPartUnit(); if ($partUnit === null) { return ''; @@ -177,7 +182,7 @@ final class PartsDataTable implements DataTableTypeInterface ->add('partCustomState', TextColumn::class, [ 'label' => $this->translator->trans('part.table.partCustomState'), 'orderField' => 'NATSORT(_partCustomState.name)', - 'render' => function($value, Part $context): string { + 'render' => function($value, $context): string { $partCustomState = $context->getPartCustomState(); if ($partCustomState === null) { @@ -202,7 +207,7 @@ final class PartsDataTable implements DataTableTypeInterface ->add('manufacturing_status', EnumColumn::class, [ 'label' => $this->translator->trans('part.table.manufacturingStatus'), 'class' => ManufacturingStatus::class, - 'render' => function (?ManufacturingStatus $status, Part $context): string { + 'render' => function (?ManufacturingStatus $status, $context): string { if ($status === null) { return ''; } @@ -233,7 +238,7 @@ final class PartsDataTable implements DataTableTypeInterface if ($this->security->isGranted('read', Project::class)) { $this->csh->add('projects', TextColumn::class, [ 'label' => $this->translator->trans('project.labelp'), - 'render' => function ($value, Part $context): string { + 'render' => function ($value, $context): string { //Only show the first 5 projects names $projects = $context->getProjects(); $tmp = ""; @@ -241,8 +246,19 @@ final class PartsDataTable implements DataTableTypeInterface $max = 5; for ($i = 0; $i < min($max, count($projects)); $i++) { - $url = $this->urlGenerator->infoURL($projects[$i]); - $tmp .= sprintf('%s', $url, htmlspecialchars($projects[$i]->getName())); + $project = $projects[$i]; + + // For DTO, projects are arrays with id and name + if (is_array($project)) { + $projectProxy = $this->entityManager->getReference(Project::class, $project['id']); + $url = $this->urlGenerator->infoURL($projectProxy); + $tmp .= sprintf('%s', $url, htmlspecialchars($project['name'])); + } else { + // For Part entity, projects are Project objects + $url = $this->urlGenerator->infoURL($project); + $tmp .= sprintf('%s', $url, htmlspecialchars($project->getName())); + } + if ($i < count($projects) - 1) { $tmp .= ", "; } @@ -260,8 +276,22 @@ final class PartsDataTable implements DataTableTypeInterface $this->csh ->add('edit', IconLinkColumn::class, [ 'label' => $this->translator->trans('part.table.edit'), - 'href' => fn($value, Part $context) => $this->urlGenerator->editURL($context), - 'disabled' => fn($value, Part $context) => !$this->security->isGranted('edit', $context), + 'href' => function ($value, $context) { + // For DTO, get a Part reference for URL generation + if ($context instanceof PartDTO) { + $partProxy = $this->entityManager->getReference(Part::class, $context->getId()); + return $this->urlGenerator->editURL($partProxy); + } + return $this->urlGenerator->editURL($context); + }, + 'disabled' => function ($value, $context) { + // For DTO, get a Part reference for permission check + if ($context instanceof PartDTO) { + $partProxy = $this->entityManager->getReference(Part::class, $context->getId()); + return !$this->security->isGranted('edit', $partProxy); + } + return !$this->security->isGranted('edit', $context); + }, 'title' => $this->translator->trans('part.table.edit.title'), ]); @@ -285,6 +315,8 @@ final class PartsDataTable implements DataTableTypeInterface new SearchCriteriaProvider(), ], 'query_modifier' => $this->addJoins(...), + // Use DTO hydration instead of full entity loading + 'dto_hydrator' => fn(array $results) => $this->partDTOHydrator->hydrateFromQueryResults($results), ]); } @@ -312,59 +344,100 @@ final class PartsDataTable implements DataTableTypeInterface $ids = array_map(static fn($row) => $row['id'], $filter_results); /* - * In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the - * full entities. - * We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance). - * The only condition should be for the IDs. - * It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong. + * Optimized query that selects only specific fields needed for table rendering. + * Instead of loading full Part entities, we select scalar values and build lightweight DTOs. + * This significantly reduces memory usage and improves performance. * - * We do not require the subqueries like amountSum here, as it is not used to render the table (and only for sorting) + * We compute aggregated amounts (amountSum, expiredAmountSum, hasUnknownAmount) using subqueries + * to avoid complex PHP iteration. */ $builder - ->select('part') - ->addSelect('category') - ->addSelect('footprint') - ->addSelect('manufacturer') - ->addSelect('partUnit') - ->addSelect('partCustomState') - ->addSelect('master_picture_attachment') - ->addSelect('footprint_attachment') - ->addSelect('partLots') - ->addSelect('orderdetails') - ->addSelect('attachments') - ->addSelect('storelocations') + // Core Part fields + ->select('part.id AS id') + ->addSelect('part.name AS name') + ->addSelect('part.ipn AS ipn') + ->addSelect('part.description AS description') + ->addSelect('part.minamount AS minamount') + ->addSelect('part.manufacturer_product_number AS manufacturer_product_number') + ->addSelect('part.mass AS mass') + ->addSelect('part.gtin AS gtin') + ->addSelect('part.tags AS tags') + ->addSelect('part.favorite AS favorite') + ->addSelect('part.needs_review AS needs_review') + ->addSelect('part.addedDate AS addedDate') + ->addSelect('part.lastModified AS lastModified') + ->addSelect('part.manufacturing_status AS manufacturing_status') + + // Related entity IDs and names + ->addSelect('category.id AS category_id') + ->addSelect('category.name AS category_name') + ->addSelect('footprint.id AS footprint_id') + ->addSelect('footprint.name AS footprint_name') + ->addSelect('manufacturer.id AS manufacturer_id') + ->addSelect('manufacturer.name AS manufacturer_name') + ->addSelect('partUnit.id AS partUnit_id') + ->addSelect('partUnit.name AS partUnit_name') + ->addSelect('partUnit.unit AS partUnit_unit') + ->addSelect('partCustomState.id AS partCustomState_id') + ->addSelect('partCustomState.name AS partCustomState_name') + ->addSelect('master_picture_attachment.id AS master_picture_attachment_id') + ->addSelect('master_picture_attachment.filename AS master_picture_attachment_filename') + ->addSelect('master_picture_attachment.name AS master_picture_attachment_name') + ->addSelect('footprint_attachment.id AS footprint_attachment_id') + ->addSelect('builtProject.id AS builtProject_id') + ->addSelect('builtProject.name AS builtProject_name') + + // Part lots for storage locations + ->addSelect('partLots.id AS partLot_id') + ->addSelect('storelocations.id AS storageLocation_id') + ->addSelect('storelocations.name AS storageLocation_name') + ->addSelect('storelocations.full_path AS storageLocation_fullPath') + + // Attachments + ->addSelect('attachments.id AS attachment_id') + + // Projects + ->addSelect('projects.id AS project_id') + ->addSelect('projects.name AS project_name') + + // Computed/aggregated amounts using subqueries + ->addSelect('( + SELECT COALESCE(SUM(pl_sum.amount), 0.0) + FROM ' . PartLot::class . ' pl_sum + WHERE pl_sum.part = part.id + AND pl_sum.instock_unknown = false + AND (pl_sum.expiration_date IS NULL OR pl_sum.expiration_date > CURRENT_DATE()) + ) AS amountSum') + ->addSelect('( + SELECT COALESCE(SUM(pl_exp.amount), 0.0) + FROM ' . PartLot::class . ' pl_exp + WHERE pl_exp.part = part.id + AND pl_exp.instock_unknown = false + AND pl_exp.expiration_date IS NOT NULL + AND pl_exp.expiration_date <= CURRENT_DATE() + ) AS expiredAmountSum') + ->addSelect('( + SELECT CASE WHEN COUNT(pl_unk.id) > 0 THEN true ELSE false END + FROM ' . PartLot::class . ' pl_unk + WHERE pl_unk.part = part.id + AND pl_unk.instock_unknown = true + ) AS hasUnknownAmount') + ->from(Part::class, 'part') ->leftJoin('part.category', 'category') - ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') - ->leftJoin('part.partLots', 'partLots') - ->leftJoin('partLots.storage_location', 'storelocations') ->leftJoin('part.footprint', 'footprint') ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') ->leftJoin('part.manufacturer', 'manufacturer') - ->leftJoin('part.orderdetails', 'orderdetails') - ->leftJoin('orderdetails.supplier', 'suppliers') - ->leftJoin('part.attachments', 'attachments') ->leftJoin('part.partUnit', 'partUnit') ->leftJoin('part.partCustomState', 'partCustomState') - ->leftJoin('part.parameters', 'parameters') + ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') + ->leftJoin('part.builtProject', 'builtProject') + ->leftJoin('part.partLots', 'partLots') + ->leftJoin('partLots.storage_location', 'storelocations') + ->leftJoin('part.attachments', 'attachments') + ->leftJoin('part.projects', 'projects') ->where('part.id IN (:ids)') - ->setParameter('ids', $ids) - - //We have to group by all elements, or only the first sub elements of an association is fetched! (caused issue #190) - ->addGroupBy('part') - ->addGroupBy('partLots') - ->addGroupBy('category') - ->addGroupBy('master_picture_attachment') - ->addGroupBy('storelocations') - ->addGroupBy('footprint') - ->addGroupBy('footprint_attachment') - ->addGroupBy('manufacturer') - ->addGroupBy('orderdetails') - ->addGroupBy('suppliers') - ->addGroupBy('attachments') - ->addGroupBy('partUnit') - ->addGroupBy('partCustomState') - ->addGroupBy('parameters'); + ->setParameter('ids', $ids); //Get the results in the same order as the IDs were passed FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');