refactored repositories and DB queries (#5026)

* removed unused teams from export order
* added new paginator for query instead of querybuilder
* added field hydrate enums
* hide PARTIAL deprecation
* never log deprecations in production
* replaced InvoiceLoader with native Doctrine feature
* prevent excessive permission queries
* support loading customers of team
* improved findByIds
* internalized API
* fix null string deprecations
This commit is contained in:
Kevin Papst
2024-08-27 10:11:19 +02:00
committed by GitHub
parent 9e3d243b4b
commit 9d933f62c0
81 changed files with 1648 additions and 1436 deletions

View File

@@ -68,6 +68,7 @@ final class ActivityController extends BaseApiController
$user = $this->getUser();
$query = new ActivityQuery();
$query->loadTeams();
$query->setCurrentUser($user);
$order = $paramFetcher->get('order');

View File

@@ -65,6 +65,7 @@ final class CustomerController extends BaseApiController
$user = $this->getUser();
$query = new CustomerQuery();
$query->loadTeams();
$query->setCurrentUser($user);
$order = $paramFetcher->get('order');

View File

@@ -74,6 +74,7 @@ final class ProjectController extends BaseApiController
$user = $this->getUser();
$query = new ProjectQuery();
$query->loadTeams();
$query->setCurrentUser($user);
$order = $paramFetcher->get('order');

View File

@@ -18,6 +18,7 @@ use App\Form\API\TeamApiEditForm;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
@@ -53,7 +54,10 @@ final class TeamController extends BaseApiController
#[Route(methods: ['GET'], path: '', name: 'get_teams')]
public function cgetAction(): Response
{
$data = $this->repository->findAll();
$query = new TeamQuery();
$query->setCurrentUser($this->getUser());
$data = $this->repository->getTeamsForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);

View File

@@ -106,25 +106,25 @@ final class ExportCreateCommand extends Command
$timezone = new \DateTimeZone($timezone);
$dateFactory = new DateTimeFactory($timezone);
$customerIDs = $input->getOption('customer');
$customerIDs = $this->optionToIntArray($input, 'customer');
$customers = [];
if (\count($customerIDs) > 0) {
$customers = $this->customerRepository->findByIds($customerIDs);
}
$projectIDs = $input->getOption('project');
$projectIDs = $this->optionToIntArray($input, 'project');
$projects = [];
if (\count($projectIDs) > 0) {
$projects = $this->projectRepository->findByIds($projectIDs);
}
$teamIDs = $input->getOption('team');
$teamIDs = $this->optionToIntArray($input, 'team');
$teams = [];
if (\count($teamIDs) > 0) {
$teams = $this->teamRepository->findByIds($teamIDs);
}
$userIDs = $input->getOption('user');
$userIDs = $this->optionToIntArray($input, 'user');
$users = [];
if (\count($userIDs) > 0) {
$users = $this->userRepository->findByIds($userIDs);
@@ -287,6 +287,25 @@ final class ExportCreateCommand extends Command
return Command::SUCCESS;
}
/**
* @return array<int>
*/
private function optionToIntArray(InputInterface $input, string $name): array
{
$results = [];
$options = $input->getOption($name);
if (\is_array($options) && \count($options) > 0) {
foreach ($options as $option) {
if (is_numeric($option)) {
$results[] = (int) $option;
}
}
}
return $results;
}
private function savePreview(Response $response, string $directory): string
{
$filename = uniqid('invoice_');

View File

@@ -51,7 +51,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/activity')]
final class ActivityController extends AbstractController
{
public function __construct(private ActivityRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ActivityService $activityService)
public function __construct(
private readonly ActivityRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityService $activityService
)
{
}
@@ -61,6 +66,7 @@ final class ActivityController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new ActivityQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
@@ -443,7 +449,6 @@ final class ActivityController extends AbstractController
}
/**
* @param ActivityQuery $query
* @return FormInterface<ActivityQuery>
*/
private function getToolbarForm(ActivityQuery $query): FormInterface
@@ -456,7 +461,7 @@ final class ActivityController extends AbstractController
}
/**
* @return FormInterface<ActivityEditForm>
* @return FormInterface<mixed>
*/
private function createEditForm(Activity $activity): FormInterface
{

View File

@@ -54,7 +54,10 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/customer')]
final class CustomerController extends AbstractController
{
public function __construct(private CustomerRepository $repository, private EventDispatcherInterface $dispatcher)
public function __construct(
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher
)
{
}
@@ -64,6 +67,7 @@ final class CustomerController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new CustomerQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
@@ -126,7 +130,6 @@ final class CustomerController extends AbstractController
}
/**
* @param CustomerQuery $query
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(CustomerQuery $query): array
@@ -539,7 +542,7 @@ final class CustomerController extends AbstractController
}
/**
* @return FormInterface<CustomerComment>
* @return FormInterface<mixed>
*/
private function getCommentForm(CustomerComment $comment): FormInterface
{

View File

@@ -73,6 +73,7 @@ final class ProjectController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new ProjectQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);

View File

@@ -88,7 +88,7 @@ final class QuickEntryController extends AbstractController
$result = $this->repository->getTimesheetResult($query);
$rows = [];
foreach ($result->getResults(true) as $timesheet) {
foreach ($result->getResults() as $timesheet) {
$i = 0;
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
$day = $timesheet->getBegin()->format('Y-m-d');

View File

@@ -19,7 +19,6 @@ use App\Reporting\YearByUser\YearByUser;
use App\Reporting\YearByUser\YearByUserForm;
use DateTime;
use DateTimeInterface;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -31,11 +30,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('report:user')]
final class UserYearController extends AbstractUserReportController
{
/**
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year', name: 'report_user_year', methods: ['GET', 'POST'])]
public function yearByUser(Request $request, SystemConfiguration $systemConfiguration): Response
{

View File

@@ -36,6 +36,7 @@ use App\Utils\PageSetup;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -144,7 +145,9 @@ abstract class TimesheetAbstractController extends AbstractController
$event = new TimesheetMetaDefinitionEvent($entry);
$this->dispatcher->dispatch($event);
$editForm = $this->getEditForm($entry, $request->get('page'));
$page = $request->get('page');
$page = is_numeric($page) ? (int) $page : 1;
$editForm = $this->getEditForm($entry, $page);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
@@ -267,7 +270,7 @@ abstract class TimesheetAbstractController extends AbstractController
if (null === $exporter) {
$form->addError(new FormError('Invalid timesheet exporter given'));
} else {
return $exporter->render($entries->getResults(true), $query);
return $exporter->render($entries->getResults(), $query);
}
}
}
@@ -484,6 +487,9 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
/**
* @param class-string<FormTypeInterface> $formClass
*/
protected function generateCreateForm(Timesheet $entry, string $formClass, string $action): FormInterface
{
$mode = $this->getTrackingMode();
@@ -504,12 +510,7 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
/**
* @param Timesheet $entry
* @param int $page
* @return FormInterface
*/
protected function getEditForm(Timesheet $entry, $page): FormInterface
private function getEditForm(Timesheet $entry, int $page): FormInterface
{
$mode = $this->getTrackingMode();
@@ -568,6 +569,9 @@ abstract class TimesheetAbstractController extends AbstractController
return 'edit_rate_own_timesheet';
}
/**
* @return class-string<FormTypeInterface>
*/
protected function getEditFormClassName(): string
{
return TimesheetEditForm::class;

View File

@@ -23,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'teams', 'comment', 'billable'])]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Constraints\Customer]
class Customer implements EntityWithMetaFields, EntityWithBudget
{

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('CustomerName', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Project'])])]
#[Serializer\VirtualProperty('CustomerAsId', exp: 'object.getCustomer() === null ? null : object.getCustomer().getId()', options: [new Serializer\SerializedName('customer'), new Serializer\Type(name: 'integer'), new Serializer\Groups(['Project', 'Team', 'Not_Expanded'])])]
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'teams', 'comment', 'billable', 'number'])]
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
#[Exporter\Expose(name: 'customer', label: 'customer', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()')]
#[Constraints\Project]
class Project implements EntityWithMetaFields, EntityWithBudget

View File

@@ -56,6 +56,10 @@ class Tag
/**
* This is ONLY here, so we can count the amount of timesheets.
*
* See TagRepository "SIZE(t.timesheets)"
* Removing this makes the count more complicated.
* Should be refactored at some point in the future.
*
* @var Collection<Timesheet>
*/
#[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')]

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\TeamRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -20,7 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_teams')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\TeamRepository')]
#[ORM\Entity(repositoryClass: TeamRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')]
@@ -310,6 +311,7 @@ class Team
}
/**
* @internal
* @return Collection<Customer>
*/
public function getCustomers(): iterable
@@ -343,6 +345,7 @@ class Team
}
/**
* @internal
* @return Collection<Project>
*/
public function getProjects(): iterable
@@ -376,6 +379,7 @@ class Team
}
/**
* @internal
* @return Collection<Activity>
*/
public function getActivities(): iterable

View File

@@ -128,7 +128,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* @var Collection<UserPreference>|null
*/
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])]
private ?Collection $preferences = null;
private ?Collection $preferences;
/**
* List of all team memberships.
*
@@ -663,11 +663,13 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\SerializedName('teams')]
#[Serializer\Groups(['User_Entity'])]
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
public function getTeams(): iterable
public function getTeams(): array
{
$teams = [];
foreach ($this->memberships as $membership) {
$teams[] = $membership->getTeam();
if ($membership->getTeam() !== null) {
$teams[] = $membership->getTeam();
}
}
return $teams;

View File

@@ -13,8 +13,6 @@ use App\Repository\Query\CustomerQuery;
/**
* Dynamically find possible meta fields for a customer query.
*
* @method CustomerQuery getQuery()
*/
final class CustomerMetaDisplayEvent extends AbstractMetaDisplayEvent
{

View File

@@ -32,13 +32,12 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
$allowCreate = $this->isGranted('create_invoice');
$allowView = $this->isGranted('view_invoice');
$allowCustomer = $this->isGranted('access', $invoice->getCustomer());
if ($allowCustomer && $allowCreate) {
if ($allowCreate) {
$event->addEdit($this->path('admin_invoice_edit', ['id' => $invoice->getId()]));
}
if ($allowCustomer && $allowView) {
if ($allowView) {
$event->addAction('download', ['url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']);
}
@@ -46,7 +45,7 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
$event->addDivider();
}
if ($allowCustomer && $allowCreate) {
if ($allowCreate) {
if (!$invoice->isPending()) {
$event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending', 'token' => $payload['token']])]);
} else {

View File

@@ -18,7 +18,9 @@ use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\ExportFilename;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\LocaleFormatExtensions;
use App\Utils\StringHelper;
@@ -461,9 +463,7 @@ abstract class AbstractSpreadsheetRenderer
}
if (isset($columns['customer-meta'])) {
/** @var CustomerQuery $customerQuery */
$customerQuery = $query->copyTo(new CustomerQuery());
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
$columns['customer-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($customerMetaFields): int {
@@ -491,7 +491,7 @@ abstract class AbstractSpreadsheetRenderer
}
if (isset($columns['project-meta'])) {
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT));
$columns['project-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($projectMetaFields): int {
foreach ($projectMetaFields as $metaField) {
@@ -518,7 +518,7 @@ abstract class AbstractSpreadsheetRenderer
}
if (isset($columns['activity-meta'])) {
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT));
$columns['activity-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($activityMetaFields): int {
foreach ($activityMetaFields as $metaField) {

View File

@@ -19,7 +19,9 @@ use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Project\ProjectStatisticService;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\ExportPolicy;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -81,13 +83,10 @@ class HtmlRenderer
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
/** @var CustomerQuery $customerQuery */
$customerQuery = $query->copyTo(new CustomerQuery());
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT));
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);

View File

@@ -44,7 +44,7 @@ final class ActivityHelper
public function getChoiceLabel(Activity $activity): string
{
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $activity->getName(), $name);
$name = str_replace(self::PATTERN_NAME, $activity->getName() ?? '', $name);
$name = str_replace(self::PATTERN_NUMBER, $activity->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $activity->getComment() ?? '', $name);
@@ -57,7 +57,7 @@ final class ActivityHelper
}
if ($name === '' || $name === self::SPACER) {
$name = $activity->getName();
$name = $activity->getName() ?? '';
}
return substr($name, 0, 110);

View File

@@ -45,7 +45,7 @@ final class CustomerHelper
public function getChoiceLabel(Customer $customer): string
{
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $customer->getName(), $name);
$name = str_replace(self::PATTERN_NAME, $customer->getName() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $customer->getComment() ?? '', $name);
$name = str_replace(self::PATTERN_NUMBER, $customer->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMPANY, $customer->getCompany() ?? '', $name);
@@ -59,7 +59,7 @@ final class CustomerHelper
}
if ($name === '' || $name === self::SPACER) {
$name = $customer->getName();
$name = $customer->getName() ?? '';
}
return substr($name, 0, 110);

View File

@@ -70,7 +70,7 @@ final class ProjectHelper
public function getChoiceLabel(Project $project): string
{
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $project->getName(), $name);
$name = str_replace(self::PATTERN_NAME, $project->getName() ?? '', $name);
$name = str_replace(self::PATTERN_NUMBER, $project->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $project->getComment() ?? '', $name);
$name = str_replace(self::PATTERN_CUSTOMER, $project->getCustomer()?->getName() ?? '', $name);
@@ -115,7 +115,7 @@ final class ProjectHelper
}
if ($name === '' || $name === self::SPACER) {
$name = $project->getName();
$name = $project->getName() ?? '';
}
return substr($name, 0, 110);

View File

@@ -27,7 +27,10 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class ProjectType extends AbstractType
{
public function __construct(private ProjectHelper $projectHelper, private CustomerHelper $customerHelper)
public function __construct(
private readonly ProjectHelper $projectHelper,
private readonly CustomerHelper $customerHelper
)
{
}

View File

@@ -22,7 +22,7 @@ class BudgetStatisticModel implements BudgetStatisticModelInterface
private ?BudgetStatistic $statistic = null;
private ?BudgetStatistic $statisticTotal = null;
public function __construct(private EntityWithBudget $entity)
public function __construct(private readonly EntityWithBudget $entity)
{
}
@@ -36,7 +36,7 @@ class BudgetStatisticModel implements BudgetStatisticModelInterface
return $this->statistic;
}
public function setStatistic(BudgetStatistic $statistic)
public function setStatistic(BudgetStatistic $statistic): void
{
$this->statistic = $statistic;
}
@@ -46,7 +46,7 @@ class BudgetStatisticModel implements BudgetStatisticModelInterface
return $this->statisticTotal;
}
public function setStatisticTotal(BudgetStatistic $statistic)
public function setStatisticTotal(BudgetStatistic $statistic): void
{
$this->statisticTotal = $statistic;
}

View File

@@ -29,7 +29,6 @@ use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectView\ProjectViewModel;
use App\Reporting\ProjectView\ProjectViewQuery;
use App\Repository\ActivityRepository;
use App\Repository\Loader\ProjectLoader;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
@@ -85,7 +84,7 @@ class ProjectStatisticService
$qb = $this->projectRepository->createQueryBuilder('p');
$qb
->select('p, c')
->select('p')
->leftJoin('p.customer', 'c')
->andWhere($qb->expr()->eq('p.visible', true))
->andWhere($qb->expr()->eq('c.visible', true))
@@ -108,15 +107,9 @@ class ProjectStatisticService
;
$this->projectRepository->addPermissionCriteria($qb, $user);
$query = $this->projectRepository->prepareProjectQuery($qb->getQuery());
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;
return $this->projectRepository->getProjects($query);
}
/**
@@ -128,6 +121,10 @@ class ProjectStatisticService
$begin = $dateRange->getBegin();
$end = $dateRange->getEnd();
if ($begin === null || $end === null) {
throw new \InvalidArgumentException('Missing date in date-range for project statistics');
}
$qb = $this->projectRepository->createQueryBuilder('p');
$qb
->select('p')
@@ -191,15 +188,9 @@ class ProjectStatisticService
}
$this->projectRepository->addPermissionCriteria($qb, $user);
$query = $this->projectRepository->prepareProjectQuery($qb->getQuery());
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;
return $this->projectRepository->getProjects($query);
}
public function getBudgetStatisticModel(Project $project, DateTimeInterface $today): ProjectBudgetStatisticModel
@@ -687,15 +678,9 @@ class ProjectStatisticService
}
$this->projectRepository->addPermissionCriteria($qb, $user);
$query = $this->projectRepository->prepareProjectQuery($qb->getQuery());
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;
return $this->projectRepository->getProjects($query);
}
/**

View File

@@ -16,14 +16,16 @@ use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\Loader\ActivityLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ActivityQueryHydrate;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
@@ -37,53 +39,53 @@ class ActivityRepository extends EntityRepository
/**
* @param Project $project
* @return Activity[]
* @return array<Activity>
*/
public function findByProject(Project $project): array
{
return $this->findBy(['project' => $project]);
$query = new ActivityQuery();
$query->addProject($project);
return $this->getActivitiesForQuery($query);
}
/**
* @param int[] $activityIds
* @return Activity[]
* @return array<Activity>
*/
public function findByIds(array $activityIds): array
{
$ids = array_filter(
array_unique($activityIds),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('a');
$qb
->where($qb->expr()->in('a.id', ':id'))
->setParameter('id', $activityIds)
->setParameter('id', $ids)
;
$activities = $qb->getQuery()->getResult();
$loader = new ActivityLoader($qb->getEntityManager(), true);
$loader->loadResults($activities);
return $activities;
return $this->getActivities($this->prepareActivityQuery($qb->getQuery()));
}
/**
* @param Activity $activity
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveActivity(Activity $activity)
public function saveActivity(Activity $activity): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($activity);
$entityManager->flush();
}
/**
* @param null|bool $visible
* @return int
*/
public function countActivity($visible = null): int
public function countActivity(?bool $visible = null): int
{
if (null !== $visible) {
return $this->count(['visible' => (bool) $visible]);
return $this->count(['visible' => $visible]);
}
return $this->count([]);
@@ -157,8 +159,7 @@ class ActivityRepository extends EntityRepository
/**
* Returns a query builder that is used for ActivityType and your own 'query_builder' option.
*
* @param ActivityFormTypeQuery $query
* @return QueryBuilder
* @internal
*/
public function getQueryBuilderForFormType(ActivityFormTypeQuery $query): QueryBuilder
{
@@ -252,15 +253,17 @@ class ActivityRepository extends EntityRepository
private function getQueryBuilderForQuery(ActivityQuery $query): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb = $this->createQueryBuilder('a');
$qb
->select('a')
->from(Activity::class, 'a')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
;
if (\count($query->getActivityIds()) > 0) {
$qb->andWhere($qb->expr()->in('a.id', ':id'))->setParameter('id', $query->getActivityIds());
}
foreach ($query->getOrderGroups() as $orderBy => $order) {
switch ($orderBy) {
case 'project':
@@ -356,6 +359,9 @@ class ActivityRepository extends EntityRepository
return ['a.name', 'a.comment', 'a.number'];
}
/**
* @return int<0, max>
*/
public function countActivitiesForQuery(ActivityQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -366,7 +372,7 @@ class ActivityRepository extends EntityRepository
->select($qb->expr()->countDistinct('a.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
public function getPagerfantaForQuery(ActivityQuery $query): Pagination
@@ -374,33 +380,41 @@ class ActivityRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
protected function getPaginatorForQuery(ActivityQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Activity>
*/
private function getPaginatorForQuery(ActivityQuery $activityQuery): PaginatorInterface
{
$counter = $this->countActivitiesForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countActivitiesForQuery($activityQuery);
$query = $this->createActivityQuery($activityQuery);
return new LoaderPaginator(new ActivityLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new ActivityLoader($this->getEntityManager()), $query, $counter);
}
/**
* @param ActivityQuery $query
* @return Activity[]
*/
public function getActivitiesForQuery(ActivityQuery $query): iterable
public function getActivitiesForQuery(ActivityQuery $query): array
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $paginator->getAll();
return $this->getActivities($this->createActivityQuery($query));
}
/**
* @param Activity $delete
* @param Activity|null $replace
* @throws \Doctrine\ORM\Exception\ORMException
* @param Query<Activity> $query
* @return Activity[]
*/
public function deleteActivity(Activity $delete, ?Activity $replace = null)
public function getActivities(Query $query): array
{
/** @var array<Activity> $activities */
$activities = $query->execute();
$loader = new ActivityLoader($this->getEntityManager());
$loader->loadResults($activities);
return $activities;
}
public function deleteActivity(Activity $delete, ?Activity $replace = null): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -425,4 +439,47 @@ class ActivityRepository extends EntityRepository
throw $ex;
}
}
/**
* @return Query<Activity>
*/
private function createActivityQuery(ActivityQuery $activityQuery): Query
{
$query = $this->getQueryBuilderForQuery($activityQuery)->getQuery();
$query = $this->prepareActivityQuery($query);
foreach ($activityQuery->getHydrate() as $hydrate) {
switch ($hydrate) {
case ActivityQueryHydrate::TEAMS:
// does not yet work, see https://github.com/doctrine/orm/pull/8391
// $query->setFetchMode(Activity::class, 'teams', ClassMetadata::FETCH_EAGER);
break;
case ActivityQueryHydrate::TEAM_MEMBER:
// does not yet work, see https://github.com/doctrine/orm/issues/11254
// $query->setFetchMode(Activity::class, 'teams', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'members', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(TeamMember::class, 'user', ClassMetadata::FETCH_EAGER);
break;
}
}
return $query;
}
/**
* @param Query<Activity> $query
* @return Query<Activity>
*/
public function prepareActivityQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Activity::class, 'meta', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Activity::class, 'project', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Project::class, 'customer', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -16,14 +16,16 @@ use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\Loader\CustomerLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\CustomerQueryHydrate;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
@@ -37,22 +39,28 @@ class CustomerRepository extends EntityRepository
/**
* @param int[] $customerIDs
* @return Customer[]
* @return array<Customer>
*/
public function findByIds(array $customerIDs): array
{
$ids = array_filter(
array_unique($customerIDs),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('c');
$qb
->where($qb->expr()->in('c.id', ':id'))
->setParameter('id', $customerIDs)
->setParameter('id', $ids)
;
$customers = $qb->getQuery()->getResult();
$loader = new CustomerLoader($qb->getEntityManager(), true);
$loader->loadResults($customers);
return $customers;
return $this->getCustomers($this->prepareCustomerQuery($qb->getQuery()), new CustomerQuery());
}
public function saveCustomer(Customer $customer): void
@@ -120,6 +128,8 @@ class CustomerRepository extends EntityRepository
/**
* Returns a query builder that is used for CustomerType and your own 'query_builder' option.
*
* @internal
*/
public function getQueryBuilderForFormType(CustomerFormTypeQuery $query): QueryBuilder
{
@@ -163,18 +173,14 @@ class CustomerRepository extends EntityRepository
private function getQueryBuilderForQuery(CustomerQuery $query): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb = $this->createQueryBuilder('c');
$qb
->select('c')
->from(Customer::class, 'c')
;
if (\count($query->getCustomerIds()) > 0) {
$qb->andWhere($qb->expr()->in('c.id', ':id'))->setParameter('id', $query->getCustomerIds());
}
if ($query->getCountry() !== null) {
$qb
->andWhere($qb->expr()->eq('c.country', ':country'))
->setParameter('country', $query->getCountry())
;
$qb->andWhere($qb->expr()->eq('c.country', ':country'))->setParameter('country', $query->getCountry());
}
foreach ($query->getOrderGroups() as $orderBy => $order) {
@@ -190,11 +196,9 @@ class CustomerRepository extends EntityRepository
}
if ($query->isShowVisible()) {
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'))->setParameter('visible', true, ParameterType::BOOLEAN);
} elseif ($query->isShowHidden()) {
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', false, ParameterType::BOOLEAN);
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'))->setParameter('visible', false, ParameterType::BOOLEAN);
}
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
@@ -227,6 +231,10 @@ class CustomerRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
/**
* FIXME make this private and remove the widget that this currently uses
* @return int<0, max>
*/
public function countCustomersForQuery(CustomerQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -237,27 +245,81 @@ class CustomerRepository extends EntityRepository
->select($qb->expr()->countDistinct('c.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
protected function getPaginatorForQuery(CustomerQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Customer>
*/
private function getPaginatorForQuery(CustomerQuery $customerQuery): PaginatorInterface
{
$counter = $this->countCustomersForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countCustomersForQuery($customerQuery);
$query = $this->createCustomerQuery($customerQuery);
return new LoaderPaginator(new CustomerLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new CustomerLoader($this->getEntityManager(), $customerQuery), $query, $counter);
}
/**
* @return Query<Customer>
*/
private function createCustomerQuery(CustomerQuery $customerQuery): Query
{
$query = $this->getQueryBuilderForQuery($customerQuery)->getQuery();
$query = $this->prepareCustomerQuery($query);
foreach ($customerQuery->getHydrate() as $hydrate) {
switch ($hydrate) {
case CustomerQueryHydrate::TEAMS:
// does not yet work, see https://github.com/doctrine/orm/pull/8391
// $query->setFetchMode(Customer::class, 'teams', ClassMetadata::FETCH_EAGER);
break;
case CustomerQueryHydrate::TEAM_MEMBER:
// does not yet work, see https://github.com/doctrine/orm/issues/11254
// $query->setFetchMode(Customer::class, 'teams', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'members', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(TeamMember::class, 'user', ClassMetadata::FETCH_EAGER);
break;
}
}
return $query;
}
/**
* @param Query<Customer> $query
* @return Query<Customer>
*/
public function prepareCustomerQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Customer::class, 'meta', ClassMetadata::FETCH_EAGER);
return $query;
}
/**
* @return Customer[]
*/
public function getCustomersForQuery(CustomerQuery $query): iterable
public function getCustomersForQuery(CustomerQuery $customerQuery): array
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $this->getCustomers($this->createCustomerQuery($customerQuery), $customerQuery);
}
return $paginator->getAll();
/**
* @param Query<Customer> $query
* @return Customer[]
*/
public function getCustomers(Query $query, CustomerQuery $customerQuery): array
{
/** @var array<Customer> $customers */
$customers = $query->execute();
$loader = new CustomerLoader($this->getEntityManager(), $customerQuery);
$loader->loadResults($customers);
return $customers;
}
public function deleteCustomer(Customer $delete, ?Customer $replace = null): void

View File

@@ -14,12 +14,13 @@ use App\Entity\Invoice;
use App\Entity\InvoiceMeta;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\Loader\InvoiceLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\InvoiceArchiveQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
@@ -254,7 +255,10 @@ class InvoiceRepository extends EntityRepository
return ['i.comment', 'customer.name', 'customer.company'];
}
public function countInvoicesForQuery(InvoiceArchiveQuery $query): int
/**
* @return int<0, max>
*/
private function countInvoicesForQuery(InvoiceArchiveQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
@@ -264,7 +268,7 @@ class InvoiceRepository extends EntityRepository
->select($qb->expr()->countDistinct('i.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
/**
@@ -273,23 +277,38 @@ class InvoiceRepository extends EntityRepository
*/
public function getInvoicesForQuery(InvoiceArchiveQuery $query): iterable
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $paginator->getAll();
return $this->createInvoiceQuery($query)->execute(); // @phpstan-ignore-line
}
protected function getPaginatorForQuery(InvoiceArchiveQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Invoice>
*/
private function getPaginatorForQuery(InvoiceArchiveQuery $query): PaginatorInterface
{
$counter = $this->countInvoicesForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$query = $this->createInvoiceQuery($query);
return new LoaderPaginator(new InvoiceLoader($qb->getEntityManager()), $qb, $counter);
return new QueryPaginator($query, $counter);
}
public function getPagerfantaForQuery(InvoiceArchiveQuery $query): Pagination
{
return new Pagination($this->getPaginatorForQuery($query), $query);
}
/**
* @return Query<Invoice>
*/
private function createInvoiceQuery(InvoiceArchiveQuery $invoiceArchiveQuery): Query
{
$query = $this->getQueryBuilderForQuery($invoiceArchiveQuery)->getQuery();
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Invoice::class, 'meta', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Invoice::class, 'user', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Invoice::class, 'customer', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -11,10 +11,11 @@ namespace App\Repository;
use App\Entity\InvoiceTemplate;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Paginator\QueryBuilderPaginator;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\BaseQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
@@ -49,12 +50,16 @@ class InvoiceTemplateRepository extends EntityRepository
return $qb;
}
protected function getPaginatorForQuery(BaseQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<InvoiceTemplate>
*/
private function getPaginatorForQuery(BaseQuery $baseQuery): PaginatorInterface
{
$counter = $this->countTemplatesForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countTemplatesForQuery($baseQuery);
/** @var Query<InvoiceTemplate> $query */
$query = $this->getQueryBuilderForQuery($baseQuery)->getQuery();
return new QueryBuilderPaginator($qb, $counter);
return new QueryPaginator($query, $counter);
}
public function getPagerfantaForQuery(BaseQuery $query): Pagination
@@ -62,6 +67,9 @@ class InvoiceTemplateRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
/**
* @return int<0, max>
*/
public function countTemplatesForQuery(BaseQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -71,7 +79,7 @@ class InvoiceTemplateRepository extends EntityRepository
->select($qb->expr()->countDistinct('t.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
public function saveTemplate(InvoiceTemplate $template): void

View File

@@ -12,65 +12,44 @@ namespace App\Repository\Loader;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Activity>
*/
final class ActivityLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
/**
* @param array<int|Activity> $results
* @param array<Activity> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($activity) {
if ($activity instanceof Activity) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$activity->getName();
$activityIds = array_filter(array_unique(array_map(function (Activity $activity) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$activity->getName();
// using reporting controller tests will show that error
$activity->getProject()?->getName();
return $activity->getId();
}
return $activity;
}, $results);
return $activity->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var Activity[] $activities */
$activities = $qb->select('PARTIAL a.{id}', 'project')
->from(Activity::class, 'a')
->leftJoin('a.project', 'project')
->andWhere($qb->expr()->isNotNull('a.project'))
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'meta')
->from(Activity::class, 'a')
->leftJoin('a.meta', 'meta')
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
$projectIds = array_filter(array_unique(array_map(function (Activity $activity) {
return $activity->getProject()?->getId();
}, $results)), function ($value) { return $value !== null; });
// global activities don't have projects
if (!empty($activities)) {
$projectIds = array_unique(array_map(function (Activity $activity) {
if (null === $activity->getProject()) {
return null;
}
return $activity->getProject()->getId();
}, $activities));
if (\count($projectIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'customer')
->from(Project::class, 'project')
@@ -79,14 +58,6 @@ final class ActivityLoader implements LoaderInterface
->getQuery()
->execute();
$customerIds = array_unique(array_map(function (Activity $activity) {
if (null === $activity->getProject()) {
return null;
}
return $activity->getProject()->getCustomer()->getId();
}, $activities));
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'teams')
->from(Project::class, 'project')
@@ -95,44 +66,28 @@ final class ActivityLoader implements LoaderInterface
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL customer.{id}', 'teams')
->from(Customer::class, 'customer')
->leftJoin('customer.teams', 'teams')
->andWhere($qb->expr()->in('customer.id', $customerIds))
->getQuery()
->execute();
}
$customerIds = array_filter(array_unique(array_map(function (Activity $activity) {
return $activity->getProject()?->getCustomer()?->getId();
}, $results)), function ($value) { return $value !== null; });
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'teams')
->from(Activity::class, 'a')
->leftJoin('a.teams', 'teams')
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
// do not load team members or leads by default, because they will only be used on detail pages
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
if ($this->fullyHydrated) {
$teamIds = [];
foreach ($activities as $activity) {
foreach ($activity->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
$teamIds = array_unique($teamIds);
if (\count($teamIds) > 0) {
if (\count($customerIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'members', 'user')
->from(Team::class, 'team')
->leftJoin('team.members', 'members')
->leftJoin('members.user', 'user')
->andWhere($qb->expr()->in('team.id', $teamIds))
$qb->select('PARTIAL customer.{id}', 'teams')
->from(Customer::class, 'customer')
->leftJoin('customer.teams', 'teams')
->andWhere($qb->expr()->in('customer.id', $customerIds))
->getQuery()
->execute();
}
}
// required on "Activity listing" page for non super-admins
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'teams')
->from(Activity::class, 'a')
->leftJoin('a.teams', 'teams')
->andWhere($qb->expr()->in('a.id', $activityIds))
->getQuery()
->execute();
}
}

View File

@@ -11,58 +11,73 @@ namespace App\Repository\Loader;
use App\Entity\Customer;
use App\Entity\Team;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\CustomerQueryHydrate;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Customer>
*/
final class CustomerLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly CustomerQuery $query
)
{
}
/**
* @param array<int|Customer> $results
* @param array<Customer> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($customer) {
if ($customer instanceof Customer) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$customer->getName();
$customerIds = array_filter(array_unique(array_map(function (Customer $customer) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$customer->getName();
return $customer->getId();
return $customer->getId();
}, $results)), function ($value) { return $value !== null; });
$hydrateTeams = false;
$hydrateTeamMembers = false;
foreach ($this->query->getHydrate() as $hydrate) {
switch ($hydrate) {
case CustomerQueryHydrate::TEAMS:
$hydrateTeams = true;
break;
case CustomerQueryHydrate::TEAM_MEMBER:
$hydrateTeams = true;
$hydrateTeamMembers = true;
break;
}
}
return $customer;
}, $results);
if (!$hydrateTeams) {
return;
}
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var Customer[] $customers */
$customers = $qb->select('PARTIAL c.{id}', 'meta')
->from(Customer::class, 'c')
->leftJoin('c.meta', 'meta')
->andWhere($qb->expr()->in('c.id', $ids))
->getQuery()
->execute();
// required where we need to check team permissions, e.g. "Customer listing"
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'teams')
->from(Customer::class, 'c')
->leftJoin('c.teams', 'teams')
->andWhere($qb->expr()->in('c.id', $ids))
->andWhere($qb->expr()->in('c.id', $customerIds))
->getQuery()
->execute();
// do not load team members or leads by default, because they will only be used on detail pages
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
if ($this->fullyHydrated) {
if ($hydrateTeamMembers) {
$teamIds = [];
foreach ($customers as $customer) {
foreach ($results as $customer) {
foreach ($customer->getTeams() as $team) {
$teamIds[] = $team->getId();
}

View File

@@ -9,6 +9,10 @@
namespace App\Repository\Loader;
/**
* @deprecated use QueryBuilderPaginator instead
* @implements LoaderInterface<mixed>
*/
final class DefaultLoader implements LoaderInterface
{
public function loadResults(array $results): void

View File

@@ -1,65 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Loader;
use App\Entity\Invoice;
use Doctrine\ORM\EntityManagerInterface;
final class InvoiceLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
/**
* @param array<int|Invoice> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
return;
}
$ids = array_map(function ($invoice) {
if ($invoice instanceof Invoice) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$invoice->getInvoiceNumber();
return $invoice->getId();
}
return $invoice;
}, $results);
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL i.{id}', 'customer')
->from(Invoice::class, 'i')
->leftJoin('i.customer', 'customer')
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL i.{id}', 'user')
->from(Invoice::class, 'i')
->leftJoin('i.user', 'user')
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL i.{id}', 'meta')
->from(Invoice::class, 'i')
->leftJoin('i.meta', 'meta')
->andWhere($qb->expr()->in('i.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -9,12 +9,15 @@
namespace App\Repository\Loader;
/**
* @template T
*/
interface LoaderInterface
{
/**
* Prepares the given database results, to prevent lazy loading.
*
* @param array $results
* @param array<array-key, T> $results
*/
public function loadResults(array $results): void;
}

View File

@@ -14,63 +14,50 @@ use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Project>
*/
final class ProjectLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $hydrateTeamMembers = false, private bool $hydrateTeams = true, private bool $hydrateMeta = true)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly bool $hydrateTeamMembers = false,
private readonly bool $hydrateTeams = true
)
{
}
/**
* @param array<int|Project> $results
* @param array<Project> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($project) {
if ($project instanceof Project) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$project->getName();
$projectIds = array_filter(array_unique(array_map(function (Project $project) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$project->getName();
// using reporting controller tests will show that error
$project->getCustomer()?->getName();
return $project->getId();
}
return $project;
}, $results);
return $project->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var Project[] $projects */
$projects = $qb->select('PARTIAL project.{id}', 'customer')
->from(Project::class, 'project')
->leftJoin('project.customer', 'customer')
->andWhere($qb->expr()->in('project.id', $ids))
->getQuery()
->execute();
$customerIds = array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $projects));
if ($this->hydrateMeta) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'meta')
->from(Project::class, 'project')
->leftJoin('project.meta', 'meta')
->andWhere($qb->expr()->in('project.id', $ids))
->getQuery()
->execute();
}
if ($this->hydrateTeams) {
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $results)), function ($value) { return $value !== null; });
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'teams')
->from(Project::class, 'project')
->leftJoin('project.teams', 'teams')
->andWhere($qb->expr()->in('project.id', $ids))
->andWhere($qb->expr()->in('project.id', $projectIds))
->getQuery()
->execute();
@@ -87,7 +74,7 @@ final class ProjectLoader implements LoaderInterface
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
if ($this->hydrateTeamMembers) {
$teamIds = [];
foreach ($projects as $project) {
foreach ($results as $project) {
foreach ($project->getTeams() as $team) {
$teamIds[] = $team->getId();
}

View File

@@ -9,52 +9,77 @@
namespace App\Repository\Loader;
use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Team>
*/
final class TeamLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly bool $loadCustomer = false
)
{
}
/**
* @param array<int|Team> $results
* @param array<Team> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($team) {
if ($team instanceof Team) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$team->getName();
$teamIds = array_filter(array_unique(array_map(function (Team $team) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$team->getName();
return $team->getId();
}
return $team;
}, $results);
return $team->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
// required wherever users are shown, e.g. on "Custom details" page
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'members', 'user')
->from(Team::class, 'team')
->leftJoin('team.members', 'members')
->leftJoin('members.user', 'user')
->andWhere($qb->expr()->in('team.id', $ids))
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
// used in UserTeamProjects widget
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'projects')
/** @var array<Team> $teams */
$teams = $qb->select('PARTIAL team.{id}', 'projects')
->from(Team::class, 'team')
->leftJoin('team.projects', 'projects')
->andWhere($qb->expr()->in('team.id', $ids))
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
$projectIds = [];
foreach ($results as $team) {
foreach ($team->getProjects() as $project) {
$projectIds[] = $project->getId();
}
}
if ($this->loadCustomer) {
// used in UserTeamProjects widget
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'customer')
->from(Project::class, 'project')
->leftJoin('project.customer', 'customer')
->andWhere($qb->expr()->in('project.id', $projectIds))
->getQuery()
->execute();
}
}
}

View File

@@ -15,46 +15,40 @@ use App\Entity\Project;
use App\Entity\Timesheet;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Timesheet>
*/
final class TimesheetLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false, private bool $basicHydrated = true)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly bool $fullyHydrated = false
)
{
}
/**
* @param array<int|Timesheet> $results
* @param array<Timesheet> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($timesheet) {
if ($timesheet instanceof Timesheet) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$timesheet->getType();
$ids = array_filter(array_unique(array_map(function (Timesheet $timesheet) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$timesheet->getType();
return $timesheet->getId();
}
return $timesheet;
}, $results);
return $timesheet->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var array<Timesheet> $timesheets */
$timesheets = $qb->select('PARTIAL t.{id}', 'project')
->from(Timesheet::class, 't')
->leftJoin('t.project', 'project')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
$projectIds = array_map(function ($timesheet) {
return $timesheet->getProject()->getId();
}, $timesheets);
$projectIds = array_filter(array_unique(array_map(function (Timesheet $timesheet) {
return $timesheet->getProject()?->getId();
}, $results)), function ($value) { return $value !== null; });
if ($this->fullyHydrated) {
$qb = $em->createQueryBuilder();
@@ -76,9 +70,9 @@ final class TimesheetLoader implements LoaderInterface
->execute();
if ($this->fullyHydrated) {
$customerIds = array_map(function ($project) {
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $projects);
}, $projects)), function ($value) { return $value !== null; });
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'meta')
@@ -89,18 +83,10 @@ final class TimesheetLoader implements LoaderInterface
->execute();
}
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'activity')
->from(Timesheet::class, 't')
->leftJoin('t.activity', 'activity')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
if ($this->fullyHydrated) {
$activityIds = array_filter(array_map(function (Timesheet $timesheet) {
return $timesheet->getActivity()?->getId();
}, $timesheets), function ($id): bool {
}, $results), function ($id): bool {
return $id !== null;
});
@@ -113,30 +99,12 @@ final class TimesheetLoader implements LoaderInterface
->execute();
}
if ($this->basicHydrated) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'user')
->from(Timesheet::class, 't')
->leftJoin('t.user', 'user')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'tags')
->from(Timesheet::class, 't')
->leftJoin('t.tags', 'tags')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'meta')
->from(Timesheet::class, 't')
->leftJoin('t.meta', 'meta')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
}
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'tags')
->from(Timesheet::class, 't')
->leftJoin('t.tags', 'tags')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -13,49 +13,54 @@ use App\Entity\Team;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<User>
*/
final class UserLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly bool $fullyHydrated = false
)
{
}
/**
* @param array<int|User> $results
* @param array<User> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($user) {
if ($user instanceof User) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$user->getDisplayName();
$userIds = array_filter(array_unique(array_map(function (User $user) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$user->getDisplayName();
return $user->getId();
}
return $user;
}, $results);
return $user->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
// this is currently needed, as it does not work via the Doctrine eager fetch method
// on user listing pages, if users are already in the unit of work from another load
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL user.{id}', 'preferences')
->from(User::class, 'user')
->leftJoin('user.preferences', 'preferences')
->andWhere($qb->expr()->in('user.id', $userIds))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
/** @var User[] $users */
$users = $qb->select('PARTIAL user.{id}', 'memberships', 'team')
->from(User::class, 'user')
->leftJoin('user.memberships', 'memberships')
->leftJoin('memberships.team', 'team')
->andWhere($qb->expr()->in('user.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL user.{id}', 'preferences')
->from(User::class, 'user')
->leftJoin('user.preferences', 'preferences')
->andWhere($qb->expr()->in('user.id', $ids))
->andWhere($qb->expr()->in('user.id', $userIds))
->getQuery()
->execute();

View File

@@ -13,9 +13,21 @@ use App\Repository\Loader\LoaderInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
* @template T
* @implements PaginatorInterface<T>
*/
final class LoaderPaginator implements PaginatorInterface
{
public function __construct(private LoaderInterface $loader, private QueryBuilder $query, private int $results)
/**
* @param LoaderInterface<T> $loader
* @param int<0, max> $results
*/
public function __construct(
private readonly LoaderInterface $loader,
private readonly QueryBuilder $queryBuilder,
private readonly int $results
)
{
}
@@ -25,12 +37,20 @@ final class LoaderPaginator implements PaginatorInterface
}
/**
* @return iterable<array-key, iterable<mixed>>
* @return Query<null, T>
*/
private function getQuery(): Query
{
return $this->queryBuilder->getQuery(); // @phpstan-ignore-line
}
/**
* @return iterable<array-key, T>
*/
public function getSlice(int $offset, int $length): iterable
{
$query = $this->query
->getQuery()
/** @var Query<null, T> $query */
$query = $this->getQuery()
->setFirstResult($offset)
->setMaxResults($length);
@@ -38,20 +58,24 @@ final class LoaderPaginator implements PaginatorInterface
}
/**
* @param Query<null, mixed> $query
* @return iterable<array-key, iterable<mixed>>
* @param Query<null, T> $query
* @return array<array-key, T>
*/
private function getResults(Query $query)
private function getResults(Query $query): array
{
/** @var array<array-key, T> $results */
$results = $query->execute();
$this->loader->loadResults($results);
return $results; // @phpstan-ignore-line
return $results;
}
/**
* @return iterable<array-key, T>
*/
public function getAll(): iterable
{
return $this->getResults($this->query->getQuery());
return $this->getResults($this->getQuery());
}
}

View File

@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Paginator;
use App\Repository\Loader\LoaderInterface;
use Doctrine\ORM\Query;
/**
* @template T
* @implements PaginatorInterface<T>
*/
final class LoaderQueryPaginator implements PaginatorInterface
{
/**
* @param LoaderInterface<T> $loader
* @param Query<T> $query
* @param int<0, max> $results
*/
public function __construct(
private readonly LoaderInterface $loader,
private readonly Query $query,
private readonly int $results
)
{
}
public function getNbResults(): int
{
return $this->results;
}
/**
* @return iterable<array-key, T>
*/
public function getSlice(int $offset, int $length): iterable
{
/** @var Query<null, T> $query */
$query = $this->query
->setFirstResult($offset)
->setMaxResults($length);
return $this->getResults($query);
}
/**
* @param Query<null, T> $query
* @return iterable<array-key, T>
*/
private function getResults(Query $query): iterable
{
/** @var array<T> $results */
$results = $query->execute();
$this->loader->loadResults($results);
return $results;
}
/**
* @return iterable<array-key, T>
*/
public function getAll(): iterable
{
return $this->getResults($this->query);
}
}

View File

@@ -11,12 +11,15 @@ namespace App\Repository\Paginator;
use Pagerfanta\Adapter\AdapterInterface;
/**
* @template-covariant T
*/
interface PaginatorInterface extends AdapterInterface
{
/**
* Returns all available results without pagination.
*
* @return iterable
* @return iterable<array-key, T>
*/
public function getAll(): iterable;
}

View File

@@ -12,9 +12,19 @@ namespace App\Repository\Paginator;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
* @deprecated use QueryPaginator instead
* @implements PaginatorInterface<mixed>
*/
final class QueryBuilderPaginator implements PaginatorInterface
{
public function __construct(private QueryBuilder $query, private int $results)
/**
* @param int<0, max> $results
*/
public function __construct(
private readonly QueryBuilder $queryBuilder,
private readonly int $results
)
{
}
@@ -24,11 +34,12 @@ final class QueryBuilderPaginator implements PaginatorInterface
}
/**
* @return iterable<array-key, iterable<mixed>>
* @return iterable<array-key, mixed>
*/
public function getSlice(int $offset, int $length): iterable
{
$query = $this->query
/** @var Query<null, mixed> $query */
$query = $this->queryBuilder
->getQuery()
->setFirstResult($offset)
->setMaxResults($length);
@@ -38,15 +49,18 @@ final class QueryBuilderPaginator implements PaginatorInterface
/**
* @param Query<null, mixed> $query
* @return iterable<array-key, iterable<mixed>>
* @return iterable<array-key, mixed>
*/
private function getResults(Query $query)
private function getResults(Query $query): iterable
{
return $query->execute(); // @phpstan-ignore-line
}
/**
* @return iterable<array-key, mixed>
*/
public function getAll(): iterable
{
return $this->getResults($this->query->getQuery());
return $this->getResults($this->queryBuilder->getQuery());
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Paginator;
use Doctrine\ORM\Query;
/**
* @template T
* @implements PaginatorInterface<T>
*/
final class QueryPaginator implements PaginatorInterface
{
/**
* @param Query<null, T> $query
* @param int<0, max> $results
*/
public function __construct(
private readonly Query $query,
private readonly int $results
)
{
}
public function getNbResults(): int
{
return $this->results;
}
/**
* @return iterable<array-key, T>
*/
public function getSlice(int $offset, int $length): iterable
{
$query = $this->query
->setFirstResult($offset)
->setMaxResults($length);
return $this->getResults($query);
}
/**
* @param Query<null, T> $query
* @return iterable<array-key, T>
*/
private function getResults(Query $query): iterable
{
return $query->execute(); // @phpstan-ignore-line
}
/**
* @return iterable<array-key, T>
*/
public function getAll(): iterable
{
return $this->getResults($this->query);
}
}

View File

@@ -17,16 +17,18 @@ use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\Loader\ProjectLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\ProjectQueryHydrate;
use App\Utils\Pagination;
use DateTime;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
@@ -40,30 +42,31 @@ class ProjectRepository extends EntityRepository
/**
* @param int[] $projectIds
* @return Project[]
* @return array<Project>
*/
public function findByIds(array $projectIds): array
{
$ids = array_filter(
array_unique($projectIds),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('p');
$qb
->where($qb->expr()->in('p.id', ':id'))
->setParameter('id', $projectIds)
->setParameter('id', $ids)
;
$projects = $qb->getQuery()->getResult();
$loader = new ProjectLoader($qb->getEntityManager(), true);
$loader->loadResults($projects);
return $projects;
return $this->getProjects($this->prepareProjectQuery($qb->getQuery()));
}
/**
* @param Project $project
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveProject(Project $project)
public function saveProject(Project $project): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($project);
@@ -71,13 +74,12 @@ class ProjectRepository extends EntityRepository
}
/**
* @param null|bool $visible
* @return int
* @return int<0, max>
*/
public function countProject($visible = null): int
public function countProject(?bool $visible = null): int
{
if (null !== $visible) {
return $this->count(['visible' => (bool) $visible]);
return $this->count(['visible' => $visible]);
}
return $this->count([]);
@@ -91,7 +93,7 @@ class ProjectRepository extends EntityRepository
}
}
public function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
@@ -140,8 +142,7 @@ class ProjectRepository extends EntityRepository
/**
* Returns a query builder that is used for ProjectType and your own 'query_builder' option.
*
* @param ProjectFormTypeQuery $query
* @return QueryBuilder
* @internal
*/
public function getQueryBuilderForFormType(ProjectFormTypeQuery $query): QueryBuilder
{
@@ -213,6 +214,10 @@ class ProjectRepository extends EntityRepository
->leftJoin('p.customer', 'c')
;
if (\count($query->getProjectIds()) > 0) {
$qb->andWhere($qb->expr()->in('p.id', ':id'))->setParameter('id', $query->getProjectIds());
}
foreach ($query->getOrderGroups() as $orderBy => $order) {
switch ($orderBy) {
case 'customer':
@@ -332,6 +337,9 @@ class ProjectRepository extends EntityRepository
return $and;
}
/**
* @return int<0, max>
*/
public function countProjectsForQuery(ProjectQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -342,7 +350,7 @@ class ProjectRepository extends EntityRepository
->select($qb->expr()->countDistinct('p.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
public function getPagerfantaForQuery(ProjectQuery $query): Pagination
@@ -350,34 +358,41 @@ class ProjectRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
private function getPaginatorForQuery(ProjectQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Project>
*/
private function getPaginatorForQuery(ProjectQuery $projectQuery): PaginatorInterface
{
$counter = $this->countProjectsForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countProjectsForQuery($projectQuery);
$query = $this->createProjectQuery($projectQuery);
return new LoaderPaginator(new ProjectLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new ProjectLoader($this->getEntityManager(), false, true), $query, $counter);
}
/**
* @param ProjectQuery $query
* @return Project[]
*/
public function getProjectsForQuery(ProjectQuery $query): iterable
public function getProjectsForQuery(ProjectQuery $query): array
{
$qb = $this->getQueryBuilderForQuery($query);
$results = $qb->getQuery()->execute();
$loader = new ProjectLoader($qb->getEntityManager());
$loader->loadResults($results);
return $results;
return $this->getProjects($this->createProjectQuery($query));
}
/**
* @param Project $delete
* @param Project|null $replace
* @throws \Doctrine\ORM\Exception\ORMException
* @param Query<Project> $query
* @return Project[]
*/
public function deleteProject(Project $delete, ?Project $replace = null)
public function getProjects(Query $query): array
{
/** @var array<Project> $projects */
$projects = $query->execute();
$loader = new ProjectLoader($this->getEntityManager(), false, true);
$loader->loadResults($projects);
return $projects;
}
public function deleteProject(Project $delete, ?Project $replace = null): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -429,17 +444,58 @@ class ProjectRepository extends EntityRepository
return $qb->getQuery()->getResult();
}
public function saveComment(ProjectComment $comment)
public function saveComment(ProjectComment $comment): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($comment);
$entityManager->flush();
}
public function deleteComment(ProjectComment $comment)
public function deleteComment(ProjectComment $comment): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($comment);
$entityManager->flush();
}
/**
* @return Query<Project>
*/
private function createProjectQuery(ProjectQuery $projectQuery): Query
{
$query = $this->getQueryBuilderForQuery($projectQuery)->getQuery();
$query = $this->prepareProjectQuery($query);
foreach ($projectQuery->getHydrate() as $hydrate) {
switch ($hydrate) {
case ProjectQueryHydrate::TEAMS:
// does not yet work, see https://github.com/doctrine/orm/pull/8391
// $query->setFetchMode(Project::class, 'teams', ClassMetadata::FETCH_EAGER);
break;
case ProjectQueryHydrate::TEAM_MEMBER:
// does not yet work, see https://github.com/doctrine/orm/issues/11254
// $query->setFetchMode(Project::class, 'teams', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'members', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(TeamMember::class, 'user', ClassMetadata::FETCH_EAGER);
break;
}
}
return $query;
}
/**
* @param Query<Project> $query
* @return Query<Project>
*/
public function prepareProjectQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Project::class, 'meta', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Project::class, 'customer', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -14,8 +14,11 @@ use App\Entity\Project;
/**
* Can be used for advanced queries with the: ActivityRepository
*/
class ActivityQuery extends ProjectQuery
class ActivityQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
use CustomerTrait;
public const ACTIVITY_ORDER_ALLOWED = [
'name',
'description' => 'comment',
@@ -33,18 +36,45 @@ class ActivityQuery extends ProjectQuery
private array $projects = [];
private bool $globalsOnly = false;
private bool $excludeGlobals = false;
/**
* @var array<int>
*/
private array $activityIds = [];
/**
* @var array<ActivityQueryHydrate>
*/
private array $hydrate = [];
public function __construct()
{
parent::__construct();
$this->setDefaults([
'orderBy' => 'name',
'customers' => [],
'projects' => [],
'globalsOnly' => false,
'excludeGlobals' => false,
'activityIds' => [],
]);
}
protected function copyFrom(BaseQuery $query): void
{
parent::copyFrom($query);
if (method_exists($query, 'getCustomers')) {
$this->setCustomers($query->getCustomers());
}
if ($query instanceof ActivityQuery) {
$this->setActivityIds($query->getActivityIds());
$this->setGlobalsOnly($query->isGlobalsOnly());
$this->setExcludeGlobals($query->isExcludeGlobals());
foreach ($query->getHydrate() as $hydrate) {
$this->addHydrate($hydrate);
}
}
}
public function isGlobalsOnly(): bool
{
return $this->globalsOnly;
@@ -115,4 +145,40 @@ class ActivityQuery extends ProjectQuery
{
return !empty($this->projects);
}
/**
* @param array<int> $ids
*/
public function setActivityIds(array $ids): void
{
$this->activityIds = $ids;
}
/**
* @return int[]
*/
public function getActivityIds(): array
{
return $this->activityIds;
}
private function addHydrate(ActivityQueryHydrate $hydrate): void
{
if (!\in_array($hydrate, $this->hydrate, true)) {
$this->hydrate[] = $hydrate;
}
}
/**
* @return ActivityQueryHydrate[]
*/
public function getHydrate(): array
{
return $this->hydrate;
}
public function loadTeams(): void
{
$this->addHydrate(ActivityQueryHydrate::TEAMS);
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
enum ActivityQueryHydrate
{
case TEAMS;
case TEAM_MEMBER;
}

View File

@@ -316,7 +316,13 @@ class BaseQuery
return array_pop($shortClass);
}
public function copyTo(BaseQuery $query): BaseQuery
/**
* @template T of BaseQuery
* @param T $query
* @return T
* @internal
*/
final public function copyTo(BaseQuery $query): BaseQuery
{
$query->setDefaults($this->defaults);
if (null !== $this->getCurrentUser()) {
@@ -336,9 +342,15 @@ class BaseQuery
$query->setVisibility($this->getVisibility());
}
$query->copyFrom($this);
return $query;
}
protected function copyFrom(BaseQuery $query): void
{
}
public function isDefaultFilter(string $filter): bool
{
if (!\array_key_exists($filter, $this->defaults)) {

View File

@@ -14,11 +14,33 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
use VisibilityTrait;
public const CUSTOMER_ORDER_ALLOWED = [
'name', 'description' => 'comment', 'country', 'number', 'homepage', 'email', 'mobile', 'fax',
'phone', 'currency', 'address', 'contact', 'company', 'vat_id', 'budget', 'timeBudget', 'visible'
'name',
'description' => 'comment',
'country', 'number',
'homepage',
'email',
'mobile',
'fax',
'phone',
'currency',
'address',
'contact',
'company',
'vat_id',
'budget',
'timeBudget',
'visible'
];
private ?string $country = null;
/**
* @var array<int>
*/
private array $customerIds = [];
/**
* @var array<CustomerQueryHydrate>
*/
private array $hydrate = [];
public function __construct()
{
@@ -26,9 +48,23 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
'orderBy' => 'name',
'visibility' => VisibilityInterface::SHOW_VISIBLE,
'country' => null,
'customerIds' => [],
]);
}
protected function copyFrom(BaseQuery $query): void
{
parent::copyFrom($query);
if ($query instanceof CustomerQuery) {
$this->setCustomerIds($query->getCustomerIds());
$this->setCountry($query->getCountry());
foreach ($query->getHydrate() as $hydrate) {
$this->addHydrate($hydrate);
}
}
}
public function getCountry(): ?string
{
return $this->country;
@@ -38,4 +74,40 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
{
$this->country = $country;
}
/**
* @param array<int> $ids
*/
public function setCustomerIds(array $ids): void
{
$this->customerIds = $ids;
}
/**
* @return int[]
*/
public function getCustomerIds(): array
{
return $this->customerIds;
}
private function addHydrate(CustomerQueryHydrate $hydrate): void
{
if (!\in_array($hydrate, $this->hydrate, true)) {
$this->hydrate[] = $hydrate;
}
}
/**
* @return CustomerQueryHydrate[]
*/
public function getHydrate(): array
{
return $this->hydrate;
}
public function loadTeams(): void
{
$this->addHydrate(CustomerQueryHydrate::TEAMS);
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
enum CustomerQueryHydrate
{
case TEAMS;
case TEAM_MEMBER;
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
use App\Entity\Customer;
trait CustomerTrait
{
/**
* @var array<Customer>
*/
private array $customers = [];
public function addCustomer(Customer $customer): self
{
$this->customers[] = $customer;
return $this;
}
/**
* @param array<Customer> $customers
* @return $this
*/
public function setCustomers(array $customers): self
{
$this->customers = $customers;
return $this;
}
/**
* @return array<Customer>
*/
public function getCustomers(): array
{
return $this->customers;
}
/**
* @return array<int>
*/
public function getCustomerIds(): array
{
return array_filter(array_values(array_unique(array_map(function (Customer $customer) {
return $customer->getId();
}, $this->customers))), function ($id) {
return $id !== null;
});
}
public function hasCustomers(): bool
{
return !empty($this->customers);
}
}

View File

@@ -9,11 +9,10 @@
namespace App\Repository\Query;
use App\Entity\Customer;
class ProjectQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
use CustomerTrait;
public const PROJECT_ORDER_ALLOWED = [
'name',
@@ -29,13 +28,17 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
'visible'
];
/**
* @var array<Customer>
*/
private array $customers = [];
private ?\DateTime $projectStart = null;
private ?\DateTime $projectEnd = null;
private ?bool $globalActivities = null;
/**
* @var array<int>
*/
private array $projectIds = [];
/**
* @var array<ProjectQueryHydrate>
*/
private array $hydrate = [];
public function __construct()
{
@@ -46,50 +49,27 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
'projectEnd' => null,
'visibility' => VisibilityInterface::SHOW_VISIBLE,
'globalActivities' => null,
'projectIds' => [],
]);
}
public function addCustomer(Customer $customer): self
protected function copyFrom(BaseQuery $query): void
{
$this->customers[] = $customer;
parent::copyFrom($query);
return $this;
}
if (method_exists($query, 'getCustomers')) {
$this->setCustomers($query->getCustomers());
}
/**
* @param array<Customer> $customers
* @return $this
*/
public function setCustomers(array $customers): self
{
$this->customers = $customers;
return $this;
}
/**
* @return array<Customer>
*/
public function getCustomers(): array
{
return $this->customers;
}
/**
* @return array<int>
*/
public function getCustomerIds(): array
{
return array_filter(array_values(array_unique(array_map(function (Customer $customer) {
return $customer->getId();
}, $this->customers))), function ($id) {
return $id !== null;
});
}
public function hasCustomers(): bool
{
return !empty($this->customers);
if ($query instanceof ProjectQuery) {
$this->setProjectIds($query->getProjectIds());
$this->setProjectStart($query->getProjectStart());
$this->setProjectEnd($query->getProjectEnd());
$this->setGlobalActivities($query->getGlobalActivities());
foreach ($query->getHydrate() as $hydrate) {
$this->addHydrate($hydrate);
}
}
}
public function getProjectStart(): ?\DateTime
@@ -125,4 +105,40 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
{
$this->globalActivities = $globalActivities;
}
/**
* @param array<int> $ids
*/
public function setProjectIds(array $ids): void
{
$this->projectIds = $ids;
}
/**
* @return int[]
*/
public function getProjectIds(): array
{
return $this->projectIds;
}
private function addHydrate(ProjectQueryHydrate $hydrate): void
{
if (!\in_array($hydrate, $this->hydrate, true)) {
$this->hydrate[] = $hydrate;
}
}
/**
* @return ProjectQueryHydrate[]
*/
public function getHydrate(): array
{
return $this->hydrate;
}
public function loadTeams(): void
{
$this->addHydrate(ProjectQueryHydrate::TEAMS);
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
enum ProjectQueryHydrate
{
case TEAMS;
case TEAM_MEMBER;
}

View File

@@ -59,6 +59,17 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
]);
}
protected function copyFrom(BaseQuery $query): void
{
parent::copyFrom($query);
if ($query instanceof TimesheetQuery) {
foreach ($this->getUsers() as $user) {
$this->addUser($user);
}
}
}
public function getMaxResults(): ?int
{
return $this->maxResults;

View File

@@ -26,6 +26,10 @@ class UserQuery extends BaseQuery implements VisibilityInterface
*/
private array $searchTeams = [];
private ?bool $systemAccount = null;
/**
* @var array<int>
*/
private array $userIds = [];
public function __construct()
{
@@ -34,6 +38,7 @@ class UserQuery extends BaseQuery implements VisibilityInterface
'searchTeams' => [],
'visibility' => VisibilityInterface::SHOW_VISIBLE,
'systemAccount' => null,
'userIds' => [],
]);
}
@@ -74,4 +79,20 @@ class UserQuery extends BaseQuery implements VisibilityInterface
{
$this->systemAccount = $systemAccount;
}
/**
* @param array<int> $ids
*/
public function setUserIds(array $ids): void
{
$this->userIds = $ids;
}
/**
* @return int[]
*/
public function getUserIds(): array
{
return $this->userIds;
}
}

View File

@@ -11,15 +11,19 @@ namespace App\Repository\Result;
use App\Entity\Timesheet;
use App\Repository\Loader\TimesheetLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Query\TimesheetQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
* @internal
*/
final class TimesheetResult
{
private ?TimesheetResultStatistic $statisticCache = null;
private bool $cachedFullyHydrated = false;
/**
* @var array<Timesheet>|null
*/
@@ -27,16 +31,22 @@ final class TimesheetResult
/**
* @internal
* @param Query<null, Timesheet> $query
*/
public function __construct(private TimesheetQuery $query, private QueryBuilder $queryBuilder)
public function __construct(
private readonly TimesheetQuery $timesheetQuery,
private readonly EntityManagerInterface $entityManager,
private readonly QueryBuilder $statisticQb,
private readonly Query $query
)
{
}
public function getStatistic(): TimesheetResultStatistic
{
if ($this->statisticCache === null) {
$withDuration = $this->query->countFilter() > 0;
$qb = clone $this->queryBuilder;
$withDuration = $this->timesheetQuery->countFilter() > 0;
$qb = clone $this->statisticQb;
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
@@ -47,6 +57,7 @@ final class TimesheetResult
$qb->addSelect('COALESCE(SUM(t.duration), 0) as duration');
}
/** @var array{'duration': int<0, max>, 'counter': int<0, max>} $result */
$result = $qb->getQuery()->getArrayResult()[0];
$duration = $withDuration ? $result['duration'] : 0;
@@ -58,39 +69,34 @@ final class TimesheetResult
public function toIterable(): iterable
{
$query = $this->queryBuilder->getQuery();
return $query->toIterable();
return $this->query->toIterable();
}
/**
* @param bool $fullyHydrated
* @return array<Timesheet>
*/
public function getResults(bool $fullyHydrated = false): array
public function getResults(): array
{
if ($this->resultCache === null || ($fullyHydrated && $this->cachedFullyHydrated === false)) {
$query = $this->queryBuilder->getQuery();
$results = $query->getResult();
if ($this->resultCache === null) {
/** @var array<Timesheet> $results */
$results = $this->query->getResult();
$loader = new TimesheetLoader($this->queryBuilder->getEntityManager(), $fullyHydrated);
$loader = new TimesheetLoader($this->entityManager, true);
$loader->loadResults($results);
$this->cachedFullyHydrated = $fullyHydrated;
$this->resultCache = $results;
}
return $this->resultCache;
}
public function getPagerfanta(bool $fullyHydrated = false): Pagination
public function getPagerfanta(): Pagination
{
$qb = clone $this->queryBuilder;
$loader = new LoaderQueryPaginator(new TimesheetLoader($this->entityManager), $this->query, $this->getStatistic()->getCount());
$loader = new LoaderPaginator(new TimesheetLoader($qb->getEntityManager(), $fullyHydrated), $qb, $this->getStatistic()->getCount());
$paginator = new Pagination($loader);
$paginator->setMaxPerPage($this->query->getPageSize());
$paginator->setCurrentPage($this->query->getPage());
$paginator->setMaxPerPage($this->timesheetQuery->getPageSize());
$paginator->setCurrentPage($this->timesheetQuery->getPage());
return $paginator;
}

View File

@@ -11,10 +11,17 @@ namespace App\Repository\Result;
final class TimesheetResultStatistic
{
public function __construct(private int $count, private int $duration)
/**
* @param int<0, max> $count
* @param int<0, max> $duration
*/
public function __construct(private readonly int $count, private readonly int $duration)
{
}
/**
* @return int<0, max>
*/
public function getCount(): int
{
return $this->count;

View File

@@ -10,7 +10,7 @@
namespace App\Repository;
use App\Entity\Tag;
use App\Repository\Paginator\QueryBuilderPaginator;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\TagFormTypeQuery;
use App\Repository\Query\TagQuery;
use App\Utils\Pagination;
@@ -115,9 +115,10 @@ class TagRepository extends EntityRepository
->resetDQLPart('orderBy')
->select($qb->expr()->count('tag.id'))
;
/** @var int<0, max> $counter */
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$paginator = new QueryBuilderPaginator($qb1, $counter);
$paginator = new QueryPaginator($qb1->getQuery(), $counter);
$pager = new Pagination($paginator);
$pager->setMaxPerPage($query->getPageSize());

View File

@@ -11,83 +11,62 @@ namespace App\Repository;
use App\Entity\Team;
use App\Entity\TeamMember;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\Loader\TeamLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TeamQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
* @extends \Doctrine\ORM\EntityRepository<Team>
* @extends EntityRepository<Team>
*/
class TeamRepository extends EntityRepository
{
/**
* @return Team[]
*/
public function findAll(): array
{
$result = parent::findAll();
$loader = new TeamLoader($this->getEntityManager());
$loader->loadResults($result);
return $result;
}
/**
* @param int[] $teamIds
* @return Team[]
*/
public function findByIds(array $teamIds): array
{
$ids = array_filter(
array_unique($teamIds),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('t');
$qb
->where($qb->expr()->in('t.id', ':id'))
->setParameter('id', $teamIds)
->setParameter('id', $ids)
;
$teams = $qb->getQuery()->getResult();
$loader = new TeamLoader($qb->getEntityManager());
$loader->loadResults($teams);
return $teams;
return $this->getTeams($this->prepareTeamQuery($qb->getQuery()));
}
/**
* @param Team $team
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveTeam(Team $team)
public function saveTeam(Team $team): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($team);
$entityManager->flush();
}
/**
* @param TeamMember $member
* @throws ORMException
*/
public function removeTeamMember(TeamMember $member)
public function removeTeamMember(TeamMember $member): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($member);
}
/**
* @param Team $team
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function deleteTeam(Team $team)
public function deleteTeam(Team $team): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($team);
@@ -96,9 +75,6 @@ class TeamRepository extends EntityRepository
/**
* Returns a query builder that is used for TeamType and your own 'query_builder' option.
*
* @param TeamQuery $query
* @return QueryBuilder
*/
public function getQueryBuilderForFormType(TeamQuery $query): QueryBuilder
{
@@ -118,32 +94,46 @@ class TeamRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
protected function getPaginatorForQuery(TeamQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Team>
*/
private function getPaginatorForQuery(TeamQuery $teamQuery): PaginatorInterface
{
$qb = $this->getQueryBuilderForQuery($query);
$qb = $this->getQueryBuilderForQuery($teamQuery);
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('t.id'))
;
/** @var int<0, max> $counter */
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query);
$query = $this->createTeamQuery($teamQuery);
return new LoaderPaginator(new TeamLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new TeamLoader($qb->getEntityManager()), $query, $counter);
}
/**
* @param TeamQuery $query
* @return Timesheet[]
* @return Team[]
*/
public function getTeamsForQuery(TeamQuery $query): iterable
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $this->getTeams($this->createTeamQuery($query));
}
return $paginator->getAll();
/**
* @param Query<Team> $query
* @return Team[]
*/
public function getTeams(Query $query): array
{
/** @var array<Team> $teams */
$teams = $query->execute();
$loader = new TeamLoader($this->getEntityManager());
$loader->loadResults($teams);
return $teams;
}
private function getQueryBuilderForQuery(TeamQuery $query): QueryBuilder
@@ -208,11 +198,9 @@ class TeamRepository extends EntityRepository
}
/**
* @param QueryBuilder $qb
* @param User|null $user
* @param Team[] $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
// make sure that all queries without a user see all user
if (null === $user && empty($teams)) {
@@ -228,7 +216,7 @@ class TeamRepository extends EntityRepository
// OR we query for all teams where the user is a member - in later case $teams is not empty
$or = $qb->expr()->orX();
// this query should limit to teams where the user is a teamlead (eg. in dropdowns or listing page)
// this query should limit to teams where the user is a teamlead (e.g. in dropdowns or listing page)
if (null !== $user) {
$qb->leftJoin('t.members', 'members');
$or->add(
@@ -255,4 +243,31 @@ class TeamRepository extends EntityRepository
$qb->andWhere($or);
}
}
/**
* @return Query<Team>
*/
private function createTeamQuery(TeamQuery $teamQuery): Query
{
$query = $this->getQueryBuilderForQuery($teamQuery)->getQuery();
$query = $this->prepareTeamQuery($query);
return $query;
}
/**
* @param Query<Team> $query
* @return Query<Team>
*/
public function prepareTeamQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
// $query->setFetchMode(Team::class, 'members', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'customers', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'projects', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'activities', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -21,7 +21,7 @@ use App\Entity\User;
use App\Model\Revenue;
use App\Model\TimesheetStatistic;
use App\Repository\Loader\TimesheetLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Result\TimesheetResult;
@@ -30,13 +30,15 @@ use DateInterval;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Exception;
use InvalidArgumentException;
/**
* @extends \Doctrine\ORM\EntityRepository<Timesheet>
* @extends EntityRepository<Timesheet>
*/
class TimesheetRepository extends EntityRepository
{
@@ -230,7 +232,6 @@ class TimesheetRepository extends EntityRepository
/**
* @param string|string[] $select
* @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException
*/
private function queryTimeRange(string|array $select, ?\DateTimeInterface $begin, ?\DateTimeInterface $end, ?User $user, ?bool $billable = null): mixed
{
@@ -346,9 +347,12 @@ class TimesheetRepository extends EntityRepository
return $qb->getQuery()->getResult();
}
return $this->getHydratedResultsByQuery($qb, false);
return $this->getHydratedResultsByQuery($qb);
}
/**
* @return int<0, max>
*/
public function countActiveEntries(?User $user = null): int
{
$qb = $this->getEntityManager()->createQueryBuilder();
@@ -366,9 +370,12 @@ class TimesheetRepository extends EntityRepository
;
}
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
/**
* @return int<0, max>
*/
public function countActiveUsers(?\DateTimeInterface $begin, ?\DateTimeInterface $end, ?bool $billable = null): int
{
$tmp = $this->queryTimeRange('COUNT(DISTINCT(t.user))', $begin, $end, null, $billable);
@@ -377,7 +384,7 @@ class TimesheetRepository extends EntityRepository
return 0;
}
return (int) $tmp;
return (int) $tmp; // @phpstan-ignore-line
}
/**
@@ -442,7 +449,10 @@ class TimesheetRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
private function getPaginatorForQuery(TimesheetQuery $query): PaginatorInterface
/**
* @return int<0, max>
*/
private function countTimesheetsForQuery(TimesheetQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
@@ -450,50 +460,60 @@ class TimesheetRepository extends EntityRepository
->resetDQLPart('orderBy')
->select($qb->expr()->count('t.id'))
;
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query);
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
return new LoaderPaginator(new TimesheetLoader($qb->getEntityManager()), $qb, $counter);
/**
* @return PaginatorInterface<Timesheet>
*/
private function getPaginatorForQuery(TimesheetQuery $timesheetQuery): PaginatorInterface
{
$counter = $this->countTimesheetsForQuery($timesheetQuery);
$query = $this->createTimesheetQuery($timesheetQuery);
return new LoaderQueryPaginator(new TimesheetLoader($this->getEntityManager()), $query, $counter);
}
/**
* When switching $fullyHydrated to true, the call gets even more expensive.
* You normally don't need this, unless you want to access deeply nested attributes for many entries.
*
* @param TimesheetQuery $query
* @param bool $fullyHydrated
* @param bool $basicHydrated
* @return Timesheet[]
*/
public function getTimesheetsForQuery(TimesheetQuery $query, bool $fullyHydrated = false, bool $basicHydrated = true): iterable
public function getTimesheetsForQuery(TimesheetQuery $query, bool $fullyHydrated = false): array
{
$qb = $this->getQueryBuilderForQuery($query);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated, $basicHydrated);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated);
}
public function getTimesheetResult(TimesheetQuery $query): TimesheetResult
{
$qb = $this->getQueryBuilderForQuery($query);
return new TimesheetResult($query, $qb);
return new TimesheetResult(
$query,
$this->getEntityManager(),
$this->getQueryBuilderForQuery($query),
$this->createTimesheetQuery($query)
);
}
/**
* @param QueryBuilder $qb
* @param bool $fullyHydrated
* @param bool $basicHydrated
* @return Timesheet[]
*/
private function getHydratedResultsByQuery(QueryBuilder $qb, bool $fullyHydrated = false, bool $basicHydrated = true): iterable
private function getHydratedResultsByQuery(QueryBuilder $qb, bool $fullyHydrated = false): array
{
$results = $qb->getQuery()->getResult();
/** @var Query<Timesheet> $query */
$query = $qb->getQuery();
$query = $this->prepareTimesheetQuery($query);
$loader = new TimesheetLoader($qb->getEntityManager(), $fullyHydrated, $basicHydrated);
$loader->loadResults($results);
/** @var array<Timesheet> $timesheets */
$timesheets = $query->getResult();
return $results;
$loader = new TimesheetLoader($qb->getEntityManager(), $fullyHydrated);
$loader->loadResults($timesheets);
return $timesheets;
}
private function getQueryBuilderForQuery(TimesheetQuery $query): QueryBuilder
@@ -660,12 +680,9 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param User $user
* @param DateTime|null $startFrom
* @param int $limit
* @return Timesheet[]
*/
public function getRecentActivities(User $user, DateTime $startFrom = null, int $limit = 10): array
public function getRecentActivities(User $user, ?\DateTimeInterface $startFrom = null, int $limit = 10): array
{
return $this->findTimesheetsById(
$user,
@@ -674,12 +691,9 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param User $user
* @param DateTime|null $startFrom
* @param int $limit
* @return array<int>
*/
public function getRecentActivityIds(User $user, DateTime $startFrom = null, int $limit = 10): array
public function getRecentActivityIds(User $user, ?\DateTimeInterface $startFrom = null, int $limit = 10): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
@@ -707,7 +721,7 @@ class TimesheetRepository extends EntityRepository
if (null !== $startFrom) {
$qb->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $startFrom);
->setParameter('begin', \DateTimeImmutable::createFromInterface($startFrom), Types::DATETIME_IMMUTABLE);
}
$qb->join('t.project', 'p');
@@ -725,13 +739,10 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param User $user
* @param array<int> $ids
* @param bool $fullyHydrated
* @param bool $basicHydrated
* @return array<Timesheet>
*/
public function findTimesheetsById(User $user, array $ids, bool $fullyHydrated = false, bool $basicHydrated = true): array
public function findTimesheetsById(User $user, array $ids): array
{
if (\count($ids) === 0) {
return [];
@@ -749,13 +760,13 @@ class TimesheetRepository extends EntityRepository
$this->addPermissionCriteria($qb, $user);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated, $basicHydrated);
return $this->getHydratedResultsByQuery($qb);
}
/**
* @param Timesheet[]|int[] $timesheets
*/
public function setExported(array $timesheets)
public function setExported(array $timesheets): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -896,4 +907,40 @@ class TimesheetRepository extends EntityRepository
return $result > 0;
}
/**
* @return Query<Timesheet>
*/
private function createTimesheetQuery(TimesheetQuery $timesheetQuery): Query
{
$query = $this->getQueryBuilderForQuery($timesheetQuery)->getQuery();
$query = $this->prepareTimesheetQuery($query);
return $query;
}
/**
* @param Query<Timesheet> $query
* @return Query<Timesheet>
*/
public function prepareTimesheetQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Timesheet::class, 'meta', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Timesheet::class, 'activity', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Timesheet::class, 'project', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Timesheet::class, 'user', ClassMetadata::FETCH_EAGER);
// not yet supported by Doctrine
// $query->setFetchMode(Activity::class, 'meta', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Project::class, 'customer', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Project::class, 'meta', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Customer::class, 'meta', ClassMetadata::FETCH_EAGER);
// ManyToMany not supported by Doctrine yet
// $query->setFetchMode(Timesheet::class, 'tags', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -15,15 +15,16 @@ use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Repository\Loader\UserLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery;
use App\Repository\Query\VisibilityInterface;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
@@ -33,7 +34,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* @extends \Doctrine\ORM\EntityRepository<User>
* @extends EntityRepository<User>
* @template-implements PasswordUpgraderInterface<User>
* @template-implements UserProviderInterface<User>
*/
@@ -48,11 +49,6 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
}
}
/**
* @param User $user
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveUser(User $user): void
{
$entityManager = $this->getEntityManager();
@@ -78,23 +74,15 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
}
}
/**
* Used to fetch a user by its ID.
*
* @param int $id
* @return null|User
*/
public function getUserById($id): ?User
public function getUserById(int $id): ?User
{
/** @var User|null $user */
$user = $this->find($id);
$users = $this->findByIds([$id]);
if ($user !== null) {
$loader = new UserLoader($this->getEntityManager(), true);
$loader->loadResults([$user]);
if (\count($users) === 1) {
return $users[0];
}
return $user;
return null;
}
/**
@@ -103,18 +91,24 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
*/
public function findByIds(array $userIds): array
{
$ids = array_filter(
array_unique($userIds),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('u');
$qb
->where($qb->expr()->in('u.id', ':id'))
->setParameter('id', $userIds)
->setParameter('id', $ids)
;
$users = $qb->getQuery()->getResult();
$loader = new UserLoader($qb->getEntityManager(), true);
$loader->loadResults($users);
return $users;
return $this->getUsers($this->prepareUserQuery($qb->getQuery()));
}
/**
@@ -135,6 +129,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
return parent::findOneBy(['username' => $username]);
}
/**
* @return int<0, max>
*/
public function countUser(?bool $enabled = null): int
{
if (null !== $enabled) {
@@ -200,11 +197,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
}
/**
* @param QueryBuilder $qb
* @param User|null $user
* @param Team[] $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
// make sure that all queries without a user see all user
if (null === $user && empty($teams)) {
@@ -259,25 +254,16 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
}
/**
* @param string $role
* @return User[]
* @internal
*/
public function findUsersWithRole(string $role): array
{
if ($role === User::ROLE_USER) {
return $this->findAll();
}
$query = new UserQuery();
$query->setRole($role);
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('u')
->from(User::class, 'u')
->andWhere('u.roles LIKE :role');
$qb->setParameter('role', '%' . $role . '%');
return $qb->getQuery()->getResult();
return $this->getUsersForQuery($query);
}
private function getQueryBuilderForQuery(UserQuery $query): QueryBuilder
@@ -377,6 +363,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
return new Pagination($this->getPaginatorForQuery($query), $query);
}
/**
* @return int<0, max>
*/
public function countUsersForQuery(UserQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -386,15 +375,18 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
->select($qb->expr()->countDistinct('u.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
protected function getPaginatorForQuery(UserQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<User>
*/
private function getPaginatorForQuery(UserQuery $userQuery): PaginatorInterface
{
$counter = $this->countUsersForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countUsersForQuery($userQuery);
$query = $this->createUserQuery($userQuery);
return new LoaderPaginator(new UserLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new UserLoader($this->getEntityManager()), $query, $counter);
}
/**
@@ -403,27 +395,25 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
*/
public function getUsersForQuery(UserQuery $query): array
{
$qb = $this->getQueryBuilderForQuery($query);
return $this->getHydratedResultsByQuery($qb);
return $this->getUsers($this->createUserQuery($query));
}
/**
* @param QueryBuilder $qb
* @param Query<User> $query
* @return User[]
*/
protected function getHydratedResultsByQuery(QueryBuilder $qb): array
public function getUsers(Query $query): array
{
/** @var array<User> $results */
$results = $qb->getQuery()->getResult();
/** @var array<User> $users */
$users = $query->execute();
$loader = new UserLoader($qb->getEntityManager());
$loader->loadResults($results);
$loader = new UserLoader($this->getEntityManager());
$loader->loadResults($users);
return $results;
return $users;
}
public function deleteUser(User $delete, ?User $replace = null)
public function deleteUser(User $delete, ?User $replace = null): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -454,9 +444,35 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
$em->remove($delete);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}
}
/**
* @return Query<User>
*/
private function createUserQuery(UserQuery $userQuery): Query
{
$query = $this->getQueryBuilderForQuery($userQuery)->getQuery();
$query = $this->prepareUserQuery($query);
return $query;
}
/**
* @param Query<User> $query
* @return Query<User>
*/
public function prepareUserQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
// $query->setFetchMode(User::class, 'preferences', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(User::class, 'supervisor', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(User::class, 'memberships', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -51,7 +51,7 @@ final class FavoriteRecordService
$all = [];
if (\count($ids) > 0) {
$timesheets = $this->repository->findTimesheetsById($user, $ids, false, false);
$timesheets = $this->repository->findTimesheetsById($user, $ids);
foreach ($timesheets as $timesheet) {
$id = $timesheet->getId();
if ($id === null) {

View File

@@ -29,7 +29,10 @@ final class DataTable implements \Countable, \IteratorAggregate
private bool $sticky = true;
private ?string $paginationRoute = null;
public function __construct(private string $tableName, private BaseQuery $query)
public function __construct(
private readonly string $tableName,
private readonly BaseQuery $query
)
{
}

View File

@@ -9,17 +9,17 @@
namespace App\Widget\Type;
use App\Entity\Project;
use App\Entity\Team;
use App\Project\ProjectStatisticService;
use App\Repository\Loader\ProjectLoader;
use App\Repository\Loader\TeamLoader;
use App\Widget\WidgetInterface;
use Doctrine\ORM\EntityManagerInterface;
final class UserTeamProjects extends AbstractWidget
{
public function __construct(private ProjectStatisticService $statisticService, private EntityManagerInterface $entityManager)
public function __construct(
private readonly ProjectStatisticService $statisticService,
private readonly EntityManagerInterface $entityManager
)
{
}
@@ -65,34 +65,23 @@ final class UserTeamProjects extends AbstractWidget
public function getData(array $options = []): mixed
{
$user = $this->getUser();
$teams = $user->getTeams();
$now = new \DateTime('now', new \DateTimeZone($user->getTimezone()));
$loader = new TeamLoader($this->entityManager);
$loader->loadResults($user->getTeams());
$loader = new TeamLoader($this->entityManager, true);
$loader->loadResults($teams);
$teamProjects = [];
$projects = [];
/** @var Team $team */
foreach ($user->getTeams() as $team) {
/** @var Project $project */
foreach ($teams as $team) {
foreach ($team->getProjects() as $project) {
if (!isset($teamProjects[$project->getId()])) {
$teamProjects[$project->getId()] = $project;
if (!$project->isVisibleAtDate($now) || !$project->hasBudgets()) {
continue;
}
$projects[$project->getId()] = $project;
}
}
$loader = new ProjectLoader($this->entityManager, false, false, false);
$loader->loadResults($teamProjects);
foreach ($teamProjects as $id => $project) {
if (!$project->isVisibleAtDate($now) || !$project->hasBudgets()) {
continue;
}
$projects[$project->getId()] = $project;
}
return $this->statisticService->getBudgetStatisticModelForProjects($projects, $now);
}
}

View File

@@ -9,16 +9,10 @@
namespace App\Widget\Type;
use App\Repository\Loader\UserLoader;
use App\Widget\WidgetInterface;
use Doctrine\ORM\EntityManagerInterface;
final class UserTeams extends AbstractWidget
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
public function getWidth(): int
{
return WidgetInterface::WIDTH_HALF;
@@ -57,12 +51,6 @@ final class UserTeams extends AbstractWidget
*/
public function getData(array $options = []): mixed
{
$user = $this->getUser();
// without this, every user would be lazy loaded
$loader = new UserLoader($this->entityManager, true);
$loader->loadResults([$user->getId()]);
return $user->getTeams();
return $this->getUser()->getTeams();
}
}