Implement DTO-based PartsDataTable with optimized queries

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-02-15 17:40:38 +00:00
parent 0aeae79a53
commit b5581e6280
6 changed files with 859 additions and 69 deletions

View file

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

View file

@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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\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<array{id: int, name: string}> */
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<array{id: int, name: string}>
*/
public function getProjects(): array
{
return $this->projects;
}
/**
* @param array<array{id: int, name: string}> $projects
*/
public function setProjects(array $projects): void
{
$this->projects = $projects;
}
}

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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\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;
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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\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;
}
};
}
}

View file

@ -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('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>',
$this->translator->trans('part.needs_review.badge'));
}
if ($context->getBuiltProject() instanceof Project) {
if ($context->getBuiltProject() !== null) {
$icon = sprintf('<i class="fa-solid fa-box-archive fa-fw me-1" title="%s"></i>',
$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(
'<a href="%s">%s%s</a>',
$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(
'<img alt="%s" src="%s" data-thumbnail="%s" class="%s" data-title="%s" data-controller="elements--hoverpic">',
'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(
'<a href="%s" title="%s">%s</a>',
$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('<br>', $tmp);
}
public function renderAmount(Part $context): string
public function renderAmount($context): string
{
$amount = $context->getAmountSum();
$expiredAmount = $context->getExpiredAmountSum();

View file

@ -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('<a href="%s">%s</a>', $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('<a href="%s">%s</a>', $url, htmlspecialchars($project['name']));
} else {
// For Part entity, projects are Project objects
$url = $this->urlGenerator->infoURL($project);
$tmp .= sprintf('<a href="%s">%s</a>', $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');