Query hints & persistent cache for latest approvals (#5176)
This commit is contained in:
@@ -13,6 +13,9 @@ use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\TimesheetQueryHint;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
@@ -23,7 +26,7 @@ final class TimesheetLoader implements LoaderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly bool $fullyHydrated = false
|
||||
private readonly ?TimesheetQuery $query = null
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -50,7 +53,7 @@ final class TimesheetLoader implements LoaderInterface
|
||||
return $timesheet->getProject()?->getId();
|
||||
}, $results)), function ($value) { return $value !== null; });
|
||||
|
||||
if ($this->fullyHydrated) {
|
||||
if ($this->query !== null && $this->query->hasQueryHint(TimesheetQueryHint::PROJECT_META_FIELDS)) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL p.{id}', 'meta')
|
||||
->from(Project::class, 'p')
|
||||
@@ -60,43 +63,67 @@ final class TimesheetLoader implements LoaderInterface
|
||||
->execute();
|
||||
}
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
/** @var array<Project> $projects */
|
||||
$projects = $qb->select('PARTIAL p.{id}', 'customer')
|
||||
->from(Project::class, 'p')
|
||||
->leftJoin('p.customer', 'customer')
|
||||
->andWhere($qb->expr()->in('p.id', $projectIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
if ($this->fullyHydrated) {
|
||||
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
|
||||
return $project->getCustomer()?->getId();
|
||||
}, $projects)), function ($value) { return $value !== null; });
|
||||
|
||||
if (\count($projectIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL c.{id}', 'meta')
|
||||
->from(Customer::class, 'c')
|
||||
->leftJoin('c.meta', 'meta')
|
||||
->andWhere($qb->expr()->in('c.id', $customerIds))
|
||||
/** @var array<Project> $projects */
|
||||
$projects = $qb->select('PARTIAL p.{id}', 'customer')
|
||||
->from(Project::class, 'p')
|
||||
->leftJoin('p.customer', 'customer')
|
||||
->andWhere($qb->expr()->in('p.id', $projectIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
if ($this->query !== null && $this->query->hasQueryHint(TimesheetQueryHint::CUSTOMER_META_FIELDS)) {
|
||||
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
|
||||
return $project->getCustomer()?->getId();
|
||||
}, $projects)), function ($value) { return $value !== null; });
|
||||
|
||||
if (\count($customerIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL c.{id}', 'meta')
|
||||
->from(Customer::class, 'c')
|
||||
->leftJoin('c.meta', 'meta')
|
||||
->andWhere($qb->expr()->in('c.id', $customerIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->fullyHydrated) {
|
||||
if ($this->query !== null && $this->query->hasQueryHint(TimesheetQueryHint::ACTIVITY_META_FIELDS)) {
|
||||
$activityIds = array_filter(array_map(function (Timesheet $timesheet) {
|
||||
return $timesheet->getActivity()?->getId();
|
||||
}, $results), function ($id): bool {
|
||||
return $id !== null;
|
||||
});
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL a.{id}', 'meta')
|
||||
->from(Activity::class, 'a')
|
||||
->leftJoin('a.meta', 'meta')
|
||||
->andWhere($qb->expr()->in('a.id', $activityIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
if (\count($activityIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL a.{id}', 'meta')
|
||||
->from(Activity::class, 'a')
|
||||
->leftJoin('a.meta', 'meta')
|
||||
->andWhere($qb->expr()->in('a.id', $activityIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->query !== null && $this->query->hasQueryHint(TimesheetQueryHint::USER_PREFERENCES)) {
|
||||
$userIds = array_filter(array_map(function (Timesheet $timesheet) {
|
||||
return $timesheet->getUser()?->getId();
|
||||
}, $results), function ($id): bool {
|
||||
return $id !== null;
|
||||
});
|
||||
|
||||
if (\count($userIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL u.{id}', 'preferences')
|
||||
->from(User::class, 'u')
|
||||
->leftJoin('u.preferences', 'preferences')
|
||||
->andWhere($qb->expr()->in('u.id', $userIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
|
||||
@@ -42,6 +42,10 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
|
||||
* @var array<User>
|
||||
*/
|
||||
private array $users = [];
|
||||
/**
|
||||
* @var array<TimesheetQueryHint>
|
||||
*/
|
||||
private array $queryHints = [];
|
||||
|
||||
public function __construct(bool $resetTimes = true)
|
||||
{
|
||||
@@ -59,6 +63,16 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
|
||||
]);
|
||||
}
|
||||
|
||||
public function addQueryHint(TimesheetQueryHint $hint): void
|
||||
{
|
||||
$this->queryHints[] = $hint;
|
||||
}
|
||||
|
||||
public function hasQueryHint(TimesheetQueryHint $hint): bool
|
||||
{
|
||||
return \in_array($hint, $this->queryHints, true);
|
||||
}
|
||||
|
||||
protected function copyFrom(BaseQuery $query): void
|
||||
{
|
||||
parent::copyFrom($query);
|
||||
|
||||
18
src/Repository/Query/TimesheetQueryHint.php
Normal file
18
src/Repository/Query/TimesheetQueryHint.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?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 TimesheetQueryHint
|
||||
{
|
||||
case USER_PREFERENCES;
|
||||
case CUSTOMER_META_FIELDS;
|
||||
case PROJECT_META_FIELDS;
|
||||
case ACTIVITY_META_FIELDS;
|
||||
}
|
||||
@@ -84,7 +84,7 @@ final class TimesheetResult
|
||||
/** @var array<Timesheet> $results */
|
||||
$results = $this->query->getResult();
|
||||
|
||||
$loader = new TimesheetLoader($this->entityManager, true);
|
||||
$loader = new TimesheetLoader($this->entityManager, $this->timesheetQuery);
|
||||
$loader->loadResults($results);
|
||||
|
||||
$this->resultCache = $results;
|
||||
@@ -95,7 +95,7 @@ final class TimesheetResult
|
||||
|
||||
public function getPagerfanta(): Pagination
|
||||
{
|
||||
$loader = new LoaderQueryPaginator(new TimesheetLoader($this->entityManager), $this->query, $this->getStatistic()->getCount());
|
||||
$loader = new LoaderQueryPaginator(new TimesheetLoader($this->entityManager, $this->timesheetQuery), $this->query, $this->getStatistic()->getCount());
|
||||
|
||||
$paginator = new Pagination($loader);
|
||||
$paginator->setMaxPerPage($this->timesheetQuery->getPageSize());
|
||||
|
||||
@@ -13,20 +13,25 @@ use App\Entity\ExportableItem;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Invoice\InvoiceItemRepositoryInterface;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Repository\Query\TimesheetQueryHint;
|
||||
|
||||
final class TimesheetInvoiceItemRepository implements InvoiceItemRepositoryInterface
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository)
|
||||
public function __construct(private readonly TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InvoiceQuery $query
|
||||
* @return ExportableItem[]
|
||||
*/
|
||||
public function getInvoiceItemsForQuery(InvoiceQuery $query): iterable
|
||||
{
|
||||
return $this->repository->getTimesheetsForQuery($query, true);
|
||||
$query->addQueryHint(TimesheetQueryHint::CUSTOMER_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::PROJECT_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::ACTIVITY_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::USER_PREFERENCES);
|
||||
|
||||
return $this->repository->getTimesheetResult($query)->getResults();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ use App\Repository\Loader\TimesheetLoader;
|
||||
use App\Repository\Paginator\LoaderQueryPaginator;
|
||||
use App\Repository\Paginator\PaginatorInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\TimesheetQueryHint;
|
||||
use App\Repository\Result\TimesheetResult;
|
||||
use App\Utils\Pagination;
|
||||
use DateInterval;
|
||||
@@ -472,12 +473,11 @@ class TimesheetRepository extends EntityRepository
|
||||
$counter = $this->countTimesheetsForQuery($timesheetQuery);
|
||||
$query = $this->createTimesheetQuery($timesheetQuery);
|
||||
|
||||
return new LoaderQueryPaginator(new TimesheetLoader($this->getEntityManager()), $query, $counter);
|
||||
return new LoaderQueryPaginator(new TimesheetLoader($this->getEntityManager(), $timesheetQuery), $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.
|
||||
* TODO @deprecated since 2.25 - use getTimesheetResult() with TimesheetQueryHint instead
|
||||
*
|
||||
* @return Timesheet[]
|
||||
*/
|
||||
@@ -485,7 +485,13 @@ class TimesheetRepository extends EntityRepository
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
|
||||
return $this->getHydratedResultsByQuery($qb, $fullyHydrated);
|
||||
if ($fullyHydrated) {
|
||||
$query->addQueryHint(TimesheetQueryHint::CUSTOMER_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::PROJECT_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::ACTIVITY_META_FIELDS);
|
||||
}
|
||||
|
||||
return $this->getHydratedResultsByQuery($qb, $query);
|
||||
}
|
||||
|
||||
public function getTimesheetResult(TimesheetQuery $query): TimesheetResult
|
||||
@@ -501,16 +507,16 @@ class TimesheetRepository extends EntityRepository
|
||||
/**
|
||||
* @return Timesheet[]
|
||||
*/
|
||||
private function getHydratedResultsByQuery(QueryBuilder $qb, bool $fullyHydrated = false): array
|
||||
private function getHydratedResultsByQuery(QueryBuilder $qb, ?TimesheetQuery $timesheetQuery = null): array
|
||||
{
|
||||
/** @var Query<Timesheet> $query */
|
||||
$query = $qb->getQuery();
|
||||
$query = $this->prepareTimesheetQuery($query);
|
||||
$query = $this->prepareTimesheetQuery($query, $timesheetQuery);
|
||||
|
||||
/** @var array<Timesheet> $timesheets */
|
||||
$timesheets = $query->getResult();
|
||||
|
||||
$loader = new TimesheetLoader($qb->getEntityManager(), $fullyHydrated);
|
||||
$loader = new TimesheetLoader($qb->getEntityManager(), $timesheetQuery);
|
||||
$loader->loadResults($timesheets);
|
||||
|
||||
return $timesheets;
|
||||
@@ -914,7 +920,7 @@ class TimesheetRepository extends EntityRepository
|
||||
private function createTimesheetQuery(TimesheetQuery $timesheetQuery): Query
|
||||
{
|
||||
$query = $this->getQueryBuilderForQuery($timesheetQuery)->getQuery();
|
||||
$query = $this->prepareTimesheetQuery($query);
|
||||
$query = $this->prepareTimesheetQuery($query, $timesheetQuery);
|
||||
|
||||
return $query;
|
||||
}
|
||||
@@ -923,7 +929,7 @@ class TimesheetRepository extends EntityRepository
|
||||
* @param Query<Timesheet> $query
|
||||
* @return Query<Timesheet>
|
||||
*/
|
||||
public function prepareTimesheetQuery(Query $query): Query
|
||||
public function prepareTimesheetQuery(Query $query, ?TimesheetQuery $timesheetQuery = null): Query
|
||||
{
|
||||
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
|
||||
|
||||
@@ -932,15 +938,6 @@ class TimesheetRepository extends EntityRepository
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,10 @@ class WorkingTimeRepository extends EntityRepository
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function getLatestApproval(User $user): ?WorkingTime
|
||||
public function getLatestApprovalDate(User $user): ?\DateTimeInterface
|
||||
{
|
||||
$qb = $this->createQueryBuilder('w');
|
||||
$qb->select('MAX(DATE(w.date))')
|
||||
$qb->select($qb->expr()->max('(DATE(w.date))'))
|
||||
->where($qb->expr()->eq('w.user', ':user'))
|
||||
->setParameter('user', $user->getId())
|
||||
->andWhere($qb->expr()->isNotNull('w.approvedAt'))
|
||||
@@ -81,14 +81,6 @@ class WorkingTimeRepository extends EntityRepository
|
||||
return null;
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('w');
|
||||
$qb->select('w')
|
||||
->where($qb->expr()->eq('w.user', ':user'))
|
||||
->setParameter('user', $user->getId())
|
||||
->andWhere($qb->expr()->eq('DATE(w.date)', 'DATE(:date)'))
|
||||
->setParameter('date', $date)
|
||||
;
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult(); // @phpstan-ignore-line
|
||||
return new \DateTimeImmutable($date . ' 00:00:00', new \DateTimeZone($user->getTimezone()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user