Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)

This commit is contained in:
Kevin Papst
2022-12-31 21:19:55 +01:00
committed by GitHub
parent 95e06746bd
commit 90a0fd8a22
2164 changed files with 83700 additions and 86426 deletions

View File

@@ -12,33 +12,24 @@ namespace App\Repository;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
/**
* @extends \Doctrine\ORM\EntityRepository<ActivityRate>
* @extends EntityRepository<ActivityRate>
*/
class ActivityRateRepository extends EntityRepository
{
public function saveRate(ActivityRate $rate)
public function saveRate(ActivityRate $rate): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($rate);
$entityManager->flush();
}
public function deleteRate(ActivityRate $rate)
public function deleteRate(ActivityRate $rate): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->remove($rate);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
$entityManager = $this->getEntityManager();
$entityManager->remove($rate);
$entityManager->flush();
}
/**

View File

@@ -15,19 +15,18 @@ use App\Entity\Project;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ActivityStatistic;
use App\Repository\Loader\ActivityLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery;
use Doctrine\DBAL\Types\Types;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
/**
* @extends \Doctrine\ORM\EntityRepository<Activity>
@@ -42,7 +41,7 @@ class ActivityRepository extends EntityRepository
* @param null $lockVersion
* @return Activity|null
*/
public function find($id, $lockMode = null, $lockVersion = null)
public function find($id, $lockMode = null, $lockVersion = null): ?Activity
{
/** @var Activity|null $activity */
$activity = parent::find($id, $lockMode, $lockVersion);
@@ -60,7 +59,7 @@ class ActivityRepository extends EntityRepository
* @param Project $project
* @return Activity[]
*/
public function findByProject(Project $project)
public function findByProject(Project $project): array
{
return $this->findBy(['project' => $project]);
}
@@ -69,7 +68,7 @@ class ActivityRepository extends EntityRepository
* @param int[] $activityIds
* @return Activity[]
*/
public function findByIds(array $activityIds)
public function findByIds(array $activityIds): array
{
$qb = $this->createQueryBuilder('a');
$qb
@@ -101,7 +100,7 @@ class ActivityRepository extends EntityRepository
* @param null|bool $visible
* @return int
*/
public function countActivity($visible = null)
public function countActivity($visible = null): int
{
if (null !== $visible) {
return $this->count(['visible' => (bool) $visible]);
@@ -110,60 +109,6 @@ class ActivityRepository extends EntityRepository
return $this->count([]);
}
/**
* @deprecated since 1.15 use ActivityStatisticService::getActivityStatistics() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param Activity $activity
* @return ActivityStatistic
*/
public function getActivityStatistics(Activity $activity): ActivityStatistic
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->where('t.activity = :activity')
->setParameter('activity', $activity)
;
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
$stats = new ActivityStatistic();
if (null !== $timesheetResult) {
$stats->setCounter($timesheetResult['amount']);
$stats->setRecordDuration($timesheetResult['duration']);
$stats->setRecordRate($timesheetResult['rate']);
$stats->setInternalRate($timesheetResult['internal_rate']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->where('t.activity = :activity')
->andWhere('t.billable = :billable')
->setParameter('activity', $activity)
->setParameter('billable', true, Types::BOOLEAN)
;
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
if (null !== $timesheetResult) {
$stats->setDurationBillable($timesheetResult['duration']);
$stats->setRateBillable($timesheetResult['rate']);
$stats->setRecordAmountBillable($timesheetResult['amount']);
}
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
@@ -229,19 +174,6 @@ class ActivityRepository extends EntityRepository
return $andX;
}
/**
* @deprecated since 1.1 - use getQueryBuilderForFormType() instead - will be removed with 2.0
* @codeCoverageIgnore
*/
public function builderForEntityType($activity, $project)
{
$query = new ActivityFormTypeQuery();
$query->addActivity($activity);
$query->addProject($project);
return $this->getQueryBuilderForFormType($query);
}
/**
* Returns a query builder that is used for ActivityType and your own 'query_builder' option.
*
@@ -261,7 +193,7 @@ class ActivityRepository extends EntityRepository
$mainQuery = $qb->expr()->andX();
$mainQuery->add($qb->expr()->eq('a.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
if (!$query->isGlobalsOnly()) {
$qb
@@ -280,7 +212,7 @@ class ActivityRepository extends EntityRepository
)
);
$qb->setParameter('is_visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('is_visible', true, ParameterType::BOOLEAN);
}
if ($query->isGlobalsOnly()) {
@@ -379,13 +311,13 @@ class ActivityRepository extends EntityRepository
)
)
);
$qb->setParameter('is_visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('is_visible', true, ParameterType::BOOLEAN);
}
if ($query->isShowVisible()) {
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
} elseif ($query->isShowHidden()) {
$qb->setParameter('visible', false, \PDO::PARAM_BOOL);
$qb->setParameter('visible', false, ParameterType::BOOLEAN);
}
}
@@ -401,13 +333,7 @@ class ActivityRepository extends EntityRepository
// projects have a setting to disallow global activities, and we check for it only
// if we query for exactly one project (usually used in dropdown queries)
if (\count($query->getProjects()) === 1) {
$project = $query->getProjects()[0];
if (!$project instanceof Project) {
$project = $this->getEntityManager()->getRepository(Project::class)->find($project);
}
if ($project instanceof Project) {
$includeGlobals = $project->isGlobalActivities();
}
$includeGlobals = $query->getProjects()[0]->isGlobalActivities();
}
if ($includeGlobals) {
$orX->add($qb->expr()->isNull('a.project'));
@@ -415,10 +341,10 @@ class ActivityRepository extends EntityRepository
}
$where->add($orX);
$qb->setParameter('project', $query->getProjects());
$qb->setParameter('project', $query->getProjectIds());
} elseif ($query->hasCustomers()) {
$where->add($qb->expr()->in('p.customer', ':customer'));
$qb->setParameter('customer', $query->getCustomers());
$qb->setParameter('customer', $query->getCustomerIds());
}
if ($where->count() > 0) {
@@ -463,9 +389,9 @@ class ActivityRepository extends EntityRepository
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function getPagerfantaForQuery(ActivityQuery $query): Pagerfanta
public function getPagerfantaForQuery(ActivityQuery $query): Pagination
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator = new Pagination($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
@@ -496,7 +422,7 @@ class ActivityRepository extends EntityRepository
/**
* @param Activity $delete
* @param Activity|null $replace
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\Exception\ORMException
*/
public function deleteActivity(Activity $delete, ?Activity $replace = null)
{

View File

@@ -0,0 +1,42 @@
<?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;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
{
public function __construct(private UserRepository $userRepository)
{
}
public function loadUserByIdentifier(string $identifier): ?UserInterface
{
return $this->userRepository->loadUserByIdentifier($identifier);
}
public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void
{
if (!($user instanceof User)) {
return;
}
try {
$user->setApiToken($newHashedPassword);
$this->userRepository->saveUser($user);
} catch (\Exception $ex) {
// happens during login: if it fails, ignore it!
}
}
}

View File

@@ -12,41 +12,65 @@ namespace App\Repository;
use App\Entity\Bookmark;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
/**
* @extends \Doctrine\ORM\EntityRepository<Bookmark>
*/
class BookmarkRepository extends EntityRepository
{
private array $userCache = [];
public function saveBookmark(Bookmark $bookmark)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($bookmark);
$entityManager->flush();
$this->clearCache($bookmark->getUser());
}
private function clearCache(User $user): void
{
$key = 'user_' . $user->getId();
if (\array_key_exists($key, $this->userCache)) {
unset($this->userCache[$key]);
}
}
public function deleteBookmark(Bookmark $bookmark)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->remove($bookmark);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
$em->remove($bookmark);
$em->flush();
$this->clearCache($bookmark->getUser());
}
public function getSearchDefaultOptions(User $user, string $name): ?Bookmark
{
return $this->findOneBy([
'user' => $user->getId(),
'type' => Bookmark::SEARCH_DEFAULT,
'name' => substr($name, 0, 50)
]);
return $this->findBookmark($user, Bookmark::SEARCH_DEFAULT, $name);
}
public function findBookmark(User $user, string $type, string $name): ?Bookmark
{
$name = mb_substr($name, 0, 50);
$key = 'user_' . $user->getId();
if (!\array_key_exists($key, $this->userCache)) {
$this->userCache[$key] = [];
$all = $this->findBy(['user' => $user->getId()]);
foreach ($all as $item) {
$this->userCache[$key][$item->getType()][mb_substr($item->getName(), 0, 50)] = $item;
}
}
if (!\array_key_exists($type, $this->userCache[$key])) {
return null;
}
if (!\array_key_exists($name, $this->userCache[$key][$type])) {
return null;
}
return $this->userCache[$key][$type][$name];
}
}

View File

@@ -16,47 +16,46 @@ use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
/**
* @extends \Doctrine\ORM\EntityRepository<Configuration>
* @extends EntityRepository<Configuration>
* @final
*/
class ConfigurationRepository extends EntityRepository implements ConfigLoaderInterface
{
private static $cacheByPrefix = [];
private static $cacheAll = [];
private static $initialized = false;
private const CACHE_KEY = 'ConfigurationRepository_All';
/**
* @var array<string, Configuration>
*/
private static array $cacheAll = [];
private static bool $initialized = false;
public function clearCache()
public function clearCache(): void
{
self::$cacheByPrefix = [];
self::$cacheAll = [];
self::$initialized = false;
$cache = $this->getEntityManager()->getConfiguration()->getResultCache();
if ($cache !== null && $cache->hasItem(self::CACHE_KEY)) {
$cache->deleteItem(self::CACHE_KEY);
}
}
private function prefillCache()
private function prefillCache(): void
{
if (self::$initialized === true) {
return;
}
/** @var Configuration[] $configs */
$configs = $this->findAll();
$query = $this->createQueryBuilder('s')->getQuery();
$query->enableResultCache(86400, self::CACHE_KEY);
$configs = $query->getResult();
foreach ($configs as $config) {
$key = substr($config->getName(), 0, strpos($config->getName(), '.'));
if (!\array_key_exists($key, self::$cacheByPrefix)) {
self::$cacheByPrefix[$key] = [];
}
self::$cacheByPrefix[$key][] = $config;
self::$cacheAll[] = $config;
self::$cacheAll[$config->getName()] = $config;
}
self::$initialized = true;
}
public function getConfigurationByName(string $name): ?Configuration
{
return $this->findOneBy(['name' => $name]);
}
public function saveConfiguration(Configuration $configuration)
public function saveConfiguration(Configuration $configuration): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($configuration);
@@ -64,31 +63,25 @@ class ConfigurationRepository extends EntityRepository implements ConfigLoaderIn
$this->clearCache();
}
public function deleteConfiguration(Configuration $configuration)
{
$entityManager = $this->getEntityManager();
$entityManager->remove($configuration);
$entityManager->flush();
$this->clearCache();
}
/**
* @param string $prefix
* @return Configuration[]
*/
public function getConfiguration(?string $prefix = null): array
public function getConfigurations(): array
{
$this->prefillCache();
if (null === $prefix) {
return self::$cacheAll;
return array_values(self::$cacheAll);
}
public function getConfiguration(string $name): ?Configuration
{
$this->prefillCache();
if (!\array_key_exists($name, self::$cacheAll)) {
return null;
}
if (!\array_key_exists($prefix, self::$cacheByPrefix)) {
return [];
}
return self::$cacheByPrefix[$prefix];
return self::$cacheAll[$name];
}
public function saveSystemConfiguration(SystemConfiguration $model)

View File

@@ -12,33 +12,24 @@ namespace App\Repository;
use App\Entity\Customer;
use App\Entity\CustomerRate;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
/**
* @extends \Doctrine\ORM\EntityRepository<CustomerRate>
*/
class CustomerRateRepository extends EntityRepository
{
public function saveRate(CustomerRate $rate)
public function saveRate(CustomerRate $rate): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($rate);
$entityManager->flush();
}
public function deleteRate(CustomerRate $rate)
public function deleteRate(CustomerRate $rate): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->remove($rate);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
$entityManager = $this->getEntityManager();
$entityManager->remove($rate);
$entityManager->flush();
}
/**

View File

@@ -9,26 +9,24 @@
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerMeta;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\CustomerStatistic;
use App\Repository\Loader\CustomerLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
/**
* @extends \Doctrine\ORM\EntityRepository<Customer>
@@ -91,94 +89,12 @@ class CustomerRepository extends EntityRepository
public function countCustomer(bool $visible = false): int
{
if ($visible) {
return $this->count(['visible' => (bool) $visible]);
return $this->count(['visible' => $visible]);
}
return $this->count([]);
}
/**
* @deprecated since 1.15 use CustomerStatisticService::getCustomerStatistics() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param Customer $customer
* @return CustomerStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getCustomerStatistics(Customer $customer): CustomerStatistic
{
$stats = new CustomerStatistic();
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->addSelect('COUNT(t.id) as amount')
->addSelect('t.billable as billable')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->andWhere('p.customer = :customer')
->setParameter('customer', $customer)
->groupBy('billable')
;
$timesheetResult = $qb->getQuery()->getResult();
if (null !== $timesheetResult) {
$amount = 0;
$duration = 0;
$rate = 0.00;
$rateInternal = 0.00;
foreach ($timesheetResult as $resultRow) {
$amount += $resultRow['amount'];
$duration += $resultRow['duration'];
$rate += $resultRow['rate'];
$rateInternal += $resultRow['internal_rate'];
if ($resultRow['billable']) {
$stats->setDurationBillable($resultRow['duration']);
$stats->setRateBillable($resultRow['rate']);
$stats->setRecordAmountBillable($resultRow['amount']);
}
}
$stats->setCounter($amount);
$stats->setRecordDuration($duration);
$stats->setRecordRate($rate);
$stats->setInternalRate($rateInternal);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('COUNT(a.id) as amount')
->from(Activity::class, 'a')
->join(Project::class, 'p', Query\Expr\Join::WITH, 'a.project = p.id')
->andWhere('a.project = p.id')
->andWhere('p.customer = :customer')
->setParameter('customer', $customer)
;
$activityResult = $qb->getQuery()->getOneOrNullResult();
if (null !== $activityResult) {
$stats->setActivityAmount($activityResult['amount']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('COUNT(p.id) as amount')
->from(Project::class, 'p')
->andWhere('p.customer = :customer')
->setParameter('customer', $customer)
;
$projectResult = $qb->getQuery()->getOneOrNullResult();
if (null !== $projectResult) {
$stats->setProjectAmount($projectResult['amount']);
}
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
@@ -226,18 +142,6 @@ class CustomerRepository extends EntityRepository
return $andX;
}
/**
* @deprecated since 1.1 - use getQueryBuilderForFormType() instead - will be removed with 2.0
* @codeCoverageIgnore
*/
public function builderForEntityType($customer)
{
$query = new CustomerFormTypeQuery();
$query->addCustomer($customer);
return $this->getQueryBuilderForFormType($query);
}
/**
* Returns a query builder that is used for CustomerType and your own 'query_builder' option.
*
@@ -255,7 +159,7 @@ class CustomerRepository extends EntityRepository
$mainQuery = $qb->expr()->andX();
$mainQuery->add($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams());
if ($permissions->count() > 0) {
@@ -307,10 +211,10 @@ class CustomerRepository extends EntityRepository
if ($query->isShowVisible()) {
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
} elseif ($query->isShowHidden()) {
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', false, \PDO::PARAM_BOOL);
$qb->setParameter('visible', false, ParameterType::BOOLEAN);
}
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
@@ -338,9 +242,9 @@ class CustomerRepository extends EntityRepository
return ['c.name', 'c.comment', 'c.company', 'c.vatId', 'c.number', 'c.contact', 'c.phone', 'c.email', 'c.address'];
}
public function getPagerfantaForQuery(CustomerQuery $query): Pagerfanta
public function getPagerfantaForQuery(CustomerQuery $query): Pagination
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator = new Pagination($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
@@ -384,7 +288,7 @@ class CustomerRepository extends EntityRepository
/**
* @param Customer $delete
* @param Customer|null $replace
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\Exception\ORMException
*/
public function deleteCustomer(Customer $delete, ?Customer $replace = null)
{

View File

@@ -9,7 +9,7 @@
namespace App\Repository;
use App\Entity\InvoiceDocument;
use App\Model\InvoiceDocument;
use Symfony\Component\Finder\Finder;
final class InvoiceDocumentRepository
@@ -17,9 +17,9 @@ final class InvoiceDocumentRepository
public const DEFAULT_DIRECTORY = 'templates/invoice/renderer/';
/**
* @var array
* @var array<string>
*/
private $documentDirs = [];
private array $documentDirs = [];
public function __construct(array $directories)
{
@@ -62,14 +62,6 @@ final class InvoiceDocumentRepository
@unlink(realpath($invoiceDocument->getFilename()));
}
/**
* @deprecated since 1.10 - will be removed with 2.0 - use getUploadDirectory() instead
*/
public function getCustomInvoiceDirectory(): string
{
return $this->getUploadDirectory();
}
public function getUploadDirectory(): string
{
// reverse the array, as bundles can register invoice directories a well (as prepend extensions)
@@ -101,7 +93,7 @@ final class InvoiceDocumentRepository
*
* @return InvoiceDocument[]
*/
public function findCustom()
public function findCustom(): array
{
$paths = [];
foreach ($this->documentDirs as $dir) {
@@ -119,7 +111,7 @@ final class InvoiceDocumentRepository
*
* @return InvoiceDocument[]
*/
public function findBuiltIn()
public function findBuiltIn(): array
{
foreach ($this->documentDirs as $dir) {
if ($dir === self::DEFAULT_DIRECTORY) {
@@ -135,7 +127,7 @@ final class InvoiceDocumentRepository
*
* @return InvoiceDocument[]
*/
public function findAll()
public function findAll(): array
{
return $this->findByPaths($this->documentDirs);
}
@@ -145,7 +137,7 @@ final class InvoiceDocumentRepository
*
* @return InvoiceDocument[]
*/
private function findByPaths(array $paths)
private function findByPaths(array $paths): array
{
$base = \dirname(\dirname(__DIR__)) . DIRECTORY_SEPARATOR;

View File

@@ -12,16 +12,15 @@ namespace App\Repository;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceMeta;
use App\Entity\InvoiceTemplate;
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\Query\InvoiceArchiveQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
/**
* @extends \Doctrine\ORM\EntityRepository<Invoice>
@@ -30,25 +29,14 @@ class InvoiceRepository extends EntityRepository
{
use RepositorySearchTrait;
/**
* @param InvoiceTemplate $invoiceTemplate
* @return void
* @deprecated replace me in 2.0
*/
public function preventTemplateUpdate(InvoiceTemplate $invoiceTemplate): void
{
$em = $this->getEntityManager();
$em->detach($invoiceTemplate);
}
public function saveInvoice(Invoice $invoice)
public function saveInvoice(Invoice $invoice): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($invoice);
$entityManager->flush();
}
public function deleteInvoice(Invoice $invoice)
public function deleteInvoice(Invoice $invoice): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($invoice);
@@ -295,9 +283,9 @@ class InvoiceRepository extends EntityRepository
return new LoaderPaginator(new InvoiceLoader($qb->getEntityManager()), $qb, $counter);
}
public function getPagerfantaForQuery(InvoiceArchiveQuery $query): Pagerfanta
public function getPagerfantaForQuery(InvoiceArchiveQuery $query): Pagination
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator = new Pagination($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());

View File

@@ -13,9 +13,9 @@ use App\Entity\InvoiceTemplate;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Paginator\QueryBuilderPaginator;
use App\Repository\Query\BaseQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
/**
* @extends \Doctrine\ORM\EntityRepository<InvoiceTemplate>
@@ -57,9 +57,9 @@ class InvoiceTemplateRepository extends EntityRepository
return new QueryBuilderPaginator($qb, $counter);
}
public function getPagerfantaForQuery(BaseQuery $query): Pagerfanta
public function getPagerfantaForQuery(BaseQuery $query): Pagination
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator = new Pagination($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
@@ -78,34 +78,15 @@ class InvoiceTemplateRepository extends EntityRepository
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* @param InvoiceTemplate $template
* @return InvoiceTemplate
* @throws RepositoryException
*/
public function saveTemplate(InvoiceTemplate $template)
public function saveTemplate(InvoiceTemplate $template): void
{
try {
$this->getEntityManager()->persist($template);
$this->getEntityManager()->flush();
} catch (\Exception $ex) {
throw new RepositoryException('Could not save InvoiceTemplate');
}
return $template;
$this->getEntityManager()->persist($template);
$this->getEntityManager()->flush();
}
/**
* @param InvoiceTemplate $template
* @throws RepositoryException
*/
public function removeTemplate(InvoiceTemplate $template)
public function removeTemplate(InvoiceTemplate $template): void
{
try {
$this->getEntityManager()->remove($template);
$this->getEntityManager()->flush();
} catch (\Exception $ex) {
throw new RepositoryException('Could not remove InvoiceTemplate');
}
$this->getEntityManager()->remove($template);
$this->getEntityManager()->flush();
}
}

View File

@@ -1,135 +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\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class ActivityIdLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
return;
}
$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();
// 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));
$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();
$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')
->leftJoin('project.teams', 'teams')
->andWhere($qb->expr()->in('project.id', $projectIds))
->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();
}
$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) {
$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))
->getQuery()
->execute();
}
}
}
}

View File

@@ -10,29 +10,126 @@
namespace App\Repository\Loader;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
final class ActivityLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param Activity[] $activities
* @param array<int|Activity> $results
*/
public function loadResults(array $activities): void
public function loadResults(array $results): void
{
$ids = array_map(function (Activity $activity) {
return $activity->getId();
}, $activities);
if (empty($results)) {
return;
}
$loader = new ActivityIdLoader($this->entityManager, $this->fullyHydrated);
$loader->loadResults($ids);
$ids = array_map(function ($activity) {
if ($activity instanceof Activity) {
return $activity->getId();
}
return $activity;
}, $results);
$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();
// 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));
$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();
$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')
->leftJoin('project.teams', 'teams')
->andWhere($qb->expr()->in('project.id', $projectIds))
->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();
}
$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) {
$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))
->getQuery()
->execute();
}
}
}
}

View File

@@ -1,81 +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\Customer;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class CustomerIdLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
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();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'teams')
->from(Customer::class, 'c')
->leftJoin('c.teams', 'teams')
->andWhere($qb->expr()->in('c.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 ($customers as $customer) {
foreach ($customer->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
$teamIds = array_unique($teamIds);
if (\count($teamIds) > 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))
->getQuery()
->execute();
}
}
}
}

View File

@@ -10,29 +10,72 @@
namespace App\Repository\Loader;
use App\Entity\Customer;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
final class CustomerLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param Customer[] $customers
* @param array<int|Customer> $results
*/
public function loadResults(array $customers): void
public function loadResults(array $results): void
{
$ids = array_map(function (Customer $customer) {
return $customer->getId();
}, $customers);
if (empty($results)) {
return;
}
$loader = new CustomerIdLoader($this->entityManager, $this->fullyHydrated);
$loader->loadResults($ids);
$ids = array_map(function ($customer) {
if ($customer instanceof Customer) {
return $customer->getId();
}
return $customer;
}, $results);
$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();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'teams')
->from(Customer::class, 'c')
->leftJoin('c.teams', 'teams')
->andWhere($qb->expr()->in('c.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 ($customers as $customer) {
foreach ($customer->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
$teamIds = array_unique($teamIds);
if (\count($teamIds) > 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))
->getQuery()
->execute();
}
}
}
}

View File

@@ -11,9 +11,6 @@ namespace App\Repository\Loader;
final class DefaultLoader implements LoaderInterface
{
/**
* @param array $results
*/
public function loadResults(array $results): void
{
// nothing to do here, the results are already fully populated

View File

@@ -1,63 +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;
/**
* @internal
*/
final class InvoiceIdLoader implements LoaderInterface
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
return;
}
$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

@@ -14,23 +14,49 @@ use Doctrine\ORM\EntityManagerInterface;
final class InvoiceLoader implements LoaderInterface
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @param Invoice[] $invoices
* @param array<int|Invoice> $results
*/
public function loadResults(array $invoices): void
public function loadResults(array $results): void
{
$ids = array_map(function (Invoice $invoice) {
return $invoice->getId();
}, $invoices);
if (empty($results)) {
return;
}
$loader = new InvoiceIdLoader($this->entityManager);
$loader->loadResults($ids);
$ids = array_map(function ($invoice) {
if ($invoice instanceof Invoice) {
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

@@ -12,7 +12,7 @@ namespace App\Repository\Loader;
interface LoaderInterface
{
/**
* Prepares the given database results, so no lazy loading will be performed.
* Prepares the given database results, to prevent lazy loading.
*
* @param array $results
*/

View File

@@ -1,102 +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\Customer;
use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class ProjectIdLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
return;
}
$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();
$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();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'teams')
->from(Project::class, 'project')
->leftJoin('project.teams', 'teams')
->andWhere($qb->expr()->in('project.id', $ids))
->getQuery()
->execute();
$customerIds = array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $projects));
$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();
// 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 ($projects as $project) {
foreach ($project->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
$teamIds = array_unique($teamIds);
if (\count($teamIds) > 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))
->getQuery()
->execute();
}
}
}
}

View File

@@ -9,30 +9,98 @@
namespace App\Repository\Loader;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
final class ProjectLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
public function __construct(private EntityManagerInterface $entityManager, private bool $hydrateTeamMembers = false, private bool $hydrateTeams = true, private bool $hydrateMeta = true)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param Project[] $projects
* @param array<int|Project> $results
*/
public function loadResults(array $projects): void
public function loadResults(array $results): void
{
$ids = array_map(function (Project $project) {
return $project->getId();
}, $projects);
if (empty($results)) {
return;
}
$loader = new ProjectIdLoader($this->entityManager, $this->fullyHydrated);
$loader->loadResults($ids);
$ids = array_map(function ($project) {
if ($project instanceof Project) {
return $project->getId();
}
return $project;
}, $results);
$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) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'teams')
->from(Project::class, 'project')
->leftJoin('project.teams', 'teams')
->andWhere($qb->expr()->in('project.id', $ids))
->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();
}
// 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->hydrateTeamMembers) {
$teamIds = [];
foreach ($projects as $project) {
foreach ($project->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
$teamIds = array_unique($teamIds);
if (\count($teamIds) > 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))
->getQuery()
->execute();
}
}
}
}

View File

@@ -1,55 +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\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class TeamIdLoader implements LoaderInterface
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @param int[] $teamIds
*/
public function loadResults(array $teamIds): void
{
if (empty($teamIds)) {
return;
}
$em = $this->entityManager;
$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))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'projects')
->from(Team::class, 'team')
->leftJoin('team.projects', 'projects')
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
}
}

View File

@@ -14,23 +14,44 @@ use Doctrine\ORM\EntityManagerInterface;
final class TeamLoader implements LoaderInterface
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
public function __construct(private EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @param Team[] $teams
* @param array<int|Team> $results
*/
public function loadResults(array $teams): void
public function loadResults(array $results): void
{
$ids = array_map(function (Team $team) {
return $team->getId();
}, $teams);
if (empty($results)) {
return;
}
$loader = new TeamIdLoader($this->entityManager);
$loader->loadResults($ids);
$ids = array_map(function ($team) {
if ($team instanceof Team) {
return $team->getId();
}
return $team;
}, $results);
$em = $this->entityManager;
$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))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'projects')
->from(Team::class, 'team')
->leftJoin('team.projects', 'projects')
->andWhere($qb->expr()->in('team.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -1,133 +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\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class TimesheetIdLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
return;
}
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
$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 $timesheet) {
return $timesheet->getProject()->getId();
}, $timesheets);
if ($this->fullyHydrated) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL p.{id}', 'meta')
->from(Project::class, 'p')
->leftJoin('p.meta', 'meta')
->andWhere($qb->expr()->in('p.id', $projectIds))
->getQuery()
->execute();
}
$qb = $em->createQueryBuilder();
$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_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $projects);
$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();
}
$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_map(function (Timesheet $timesheet) {
return $timesheet->getActivity()->getId();
}, $timesheets);
$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();
}
$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();
}
}

View File

@@ -9,30 +9,129 @@
namespace App\Repository\Loader;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use Doctrine\ORM\EntityManagerInterface;
final class TimesheetLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false, private bool $basicHydrated = true)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param Timesheet[] $timesheets
* @param array<int|Timesheet> $results
*/
public function loadResults(array $timesheets): void
public function loadResults(array $results): void
{
$ids = array_map(function (Timesheet $timesheet) {
return $timesheet->getId();
if (empty($results)) {
return;
}
$ids = array_map(function ($timesheet) {
if ($timesheet instanceof Timesheet) {
return $timesheet->getId();
}
return $timesheet;
}, $results);
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
$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 $timesheet) {
return $timesheet->getProject()->getId();
}, $timesheets);
$loader = new TimesheetIdLoader($this->entityManager, $this->fullyHydrated);
$loader->loadResults($ids);
if ($this->fullyHydrated) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL p.{id}', 'meta')
->from(Project::class, 'p')
->leftJoin('p.meta', 'meta')
->andWhere($qb->expr()->in('p.id', $projectIds))
->getQuery()
->execute();
}
$qb = $em->createQueryBuilder();
$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_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $projects);
$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();
}
$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 {
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 ($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();
}
}
}

View File

@@ -1,83 +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\Team;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class UserIdLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
return;
}
$em = $this->entityManager;
$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))
->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 ($users as $user) {
foreach ($user->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
$teamIds = array_unique($teamIds);
if (\count($teamIds) > 0) {
$qb = $em->createQueryBuilder();
/** @var Team[] $teams */
$teams = $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))
->getQuery()
->execute();
}
}
}
}

View File

@@ -9,30 +9,75 @@
namespace App\Repository\Loader;
use App\Entity\Team;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
final class UserLoader implements LoaderInterface
{
private $entityManager;
private $fullyHydrated;
public function __construct(EntityManagerInterface $entityManager, bool $fullyHydrated = false)
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
{
$this->entityManager = $entityManager;
$this->fullyHydrated = $fullyHydrated;
}
/**
* @param User[] $users
* @param array<int|User> $results
*/
public function loadResults(array $users): void
public function loadResults(array $results): void
{
$ids = array_map(function (User $user) {
return $user->getId();
}, $users);
if (empty($results)) {
return;
}
$loader = new UserIdLoader($this->entityManager, $this->fullyHydrated);
$loader->loadResults($ids);
$ids = array_map(function ($user) {
if ($user instanceof User) {
return $user->getId();
}
return $user;
}, $results);
$em = $this->entityManager;
$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))
->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 ($users as $user) {
foreach ($user->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
$teamIds = array_unique($teamIds);
if (\count($teamIds) > 0) {
$qb = $em->createQueryBuilder();
/** @var Team[] $teams */
$teams = $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))
->getQuery()
->execute();
}
}
}
}

View File

@@ -15,38 +15,16 @@ use Doctrine\ORM\QueryBuilder;
final class LoaderPaginator implements PaginatorInterface
{
/**
* @var QueryBuilder
*/
private $query;
/**
* @var int
*/
private $results = 0;
/**
* @var LoaderInterface
*/
private $loader;
public function __construct(LoaderInterface $loader, QueryBuilder $query, int $results)
public function __construct(private LoaderInterface $loader, private QueryBuilder $query, private int $results)
{
$this->loader = $loader;
$this->query = $query;
$this->results = $results;
}
/**
* {@inheritdoc}
*/
public function getNbResults()
public function getNbResults(): int
{
return $this->results;
}
/**
* {@inheritdoc}
*/
public function getSlice($offset, $length)
public function getSlice(int $offset, int $length): iterable
{
$query = $this->query
->getQuery()

View File

@@ -14,33 +14,16 @@ use Doctrine\ORM\QueryBuilder;
final class QueryBuilderPaginator implements PaginatorInterface
{
/**
* @var QueryBuilder
*/
private $query;
/**
* @var int
*/
private $results = 0;
public function __construct(QueryBuilder $query, int $results)
public function __construct(private QueryBuilder $query, private int $results)
{
$this->query = $query;
$this->results = $results;
}
/**
* {@inheritdoc}
*/
public function getNbResults()
public function getNbResults(): int
{
return $this->results;
}
/**
* {@inheritdoc}
*/
public function getSlice($offset, $length)
public function getSlice(int $offset, int $length): iterable
{
$query = $this->query
->getQuery()

View File

@@ -12,33 +12,24 @@ namespace App\Repository;
use App\Entity\Project;
use App\Entity\ProjectRate;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
/**
* @extends \Doctrine\ORM\EntityRepository<ProjectRate>
*/
class ProjectRateRepository extends EntityRepository
{
public function saveRate(ProjectRate $rate)
public function saveRate(ProjectRate $rate): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($rate);
$entityManager->flush();
}
public function deleteRate(ProjectRate $rate)
public function deleteRate(ProjectRate $rate): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->remove($rate);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
$entityManager = $this->getEntityManager();
$entityManager->remove($rate);
$entityManager->flush();
}
/**

View File

@@ -16,20 +16,20 @@ use App\Entity\ProjectMeta;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ProjectStatistic;
use App\Repository\Loader\ProjectLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery;
use App\Utils\Pagination;
use DateTime;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
/**
* @extends \Doctrine\ORM\EntityRepository<Project>
@@ -44,7 +44,7 @@ class ProjectRepository extends EntityRepository
* @param null $lockVersion
* @return Project|null
*/
public function find($id, $lockMode = null, $lockVersion = null)
public function find($id, $lockMode = null, $lockVersion = null): ?Project
{
/** @var Project|null $project */
$project = parent::find($id, $lockMode, $lockVersion);
@@ -62,7 +62,7 @@ class ProjectRepository extends EntityRepository
* @param int[] $projectIds
* @return Project[]
*/
public function findByIds(array $projectIds)
public function findByIds(array $projectIds): array
{
$qb = $this->createQueryBuilder('p');
$qb
@@ -94,7 +94,7 @@ class ProjectRepository extends EntityRepository
* @param null|bool $visible
* @return int
*/
public function countProject($visible = null)
public function countProject($visible = null): int
{
if (null !== $visible) {
return $this->count(['visible' => (bool) $visible]);
@@ -103,89 +103,6 @@ class ProjectRepository extends EntityRepository
return $this->count([]);
}
/**
* @deprecated since 1.15 use ProjectStatisticService::getProjectStatistics() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param Project $project
* @param DateTime|null $begin
* @param DateTime|null $end
* @return ProjectStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getProjectStatistics(Project $project, ?DateTime $begin = null, ?DateTime $end = null): ProjectStatistic
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->andWhere('t.project = :project')
->setParameter('project', $project)
;
// to calculate a budget at a certain point in time
if (null !== $end) {
$qb->andWhere($qb->expr()->lte('t.end', ':end'))
->setParameter('end', $end);
}
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
$stats = new ProjectStatistic();
if (null !== $timesheetResult) {
$stats->setCounter($timesheetResult['amount']);
$stats->setRecordDuration($timesheetResult['duration']);
$stats->setRecordRate($timesheetResult['rate']);
$stats->setInternalRate($timesheetResult['internal_rate']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->andWhere('t.project = :project')
->andWhere('t.billable = :billable')
->setParameter('project', $project)
->setParameter('billable', true, Types::BOOLEAN)
;
// to calculate a budget at a certain point in time
if (null !== $end) {
$qb->andWhere($qb->expr()->lte('t.end', ':end'))
->setParameter('end', $end);
}
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
if (null !== $timesheetResult) {
$stats->setDurationBillable($timesheetResult['duration']);
$stats->setRateBillable($timesheetResult['rate']);
$stats->setRecordAmountBillable($timesheetResult['amount']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Activity::class, 'a')
->select('COUNT(a.id) as amount')
->andWhere('a.project = :project')
->setParameter('project', $project)
;
$resultActivities = $qb->getQuery()->getOneOrNullResult();
if (null !== $resultActivities) {
$stats->setActivityAmount($resultActivities['amount']);
}
return $stats;
}
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
@@ -240,19 +157,6 @@ class ProjectRepository extends EntityRepository
return $andX;
}
/**
* @deprecated since 1.1 - use getQueryBuilderForFormType() istead - will be removed with 2.0
* @codeCoverageIgnore
*/
public function builderForEntityType($project, $customer)
{
$query = new ProjectFormTypeQuery();
$query->addProject($project);
$query->addCustomer($customer);
return $this->getQueryBuilderForFormType($query);
}
/**
* Returns a query builder that is used for ProjectType and your own 'query_builder' option.
*
@@ -278,10 +182,10 @@ class ProjectRepository extends EntityRepository
$mainQuery = $qb->expr()->andX();
$mainQuery->add($qb->expr()->eq('p.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
$mainQuery->add($qb->expr()->eq('c.visible', ':customer_visible'));
$qb->setParameter('customer_visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('customer_visible', true, ParameterType::BOOLEAN);
if (!$query->isIgnoreDate()) {
$andx = $this->addProjectStartAndEndDate($qb, $query->getProjectStart(), $query->getProjectEnd());
@@ -354,17 +258,17 @@ class ProjectRepository extends EntityRepository
;
if ($query->isShowVisible()) {
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
} elseif ($query->isShowHidden()) {
$qb->setParameter('visible', false, \PDO::PARAM_BOOL);
$qb->setParameter('visible', false, ParameterType::BOOLEAN);
}
$qb->setParameter('customer_visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('customer_visible', true, ParameterType::BOOLEAN);
}
if ($query->hasCustomers()) {
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
->setParameter('customer', $query->getCustomers());
->setParameter('customer', $query->getCustomerIds());
}
if ($query->getGlobalActivities() !== null) {
@@ -417,11 +321,11 @@ class ProjectRepository extends EntityRepository
$and->add(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->lte('DATE(p.start)', 'DATE(:start)'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->gte('DATE(p.end)', 'DATE(:start)'),
$qb->expr()->isNull('p.end')
)
)
@@ -433,11 +337,11 @@ class ProjectRepository extends EntityRepository
$and->add(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->gte('DATE(p.end)', 'DATE(:end)'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->lte('DATE(p.start)', 'DATE(:end)'),
$qb->expr()->isNull('p.start')
)
)
@@ -461,9 +365,9 @@ class ProjectRepository extends EntityRepository
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function getPagerfantaForQuery(ProjectQuery $query): Pagerfanta
public function getPagerfantaForQuery(ProjectQuery $query): Pagination
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator = new Pagination($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
@@ -495,7 +399,7 @@ class ProjectRepository extends EntityRepository
/**
* @param Project $delete
* @param Project|null $replace
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\Exception\ORMException
*/
public function deleteProject(Project $delete, ?Project $replace = null)
{

View File

@@ -14,16 +14,13 @@ use App\Entity\Project;
final class ActivityFormTypeQuery extends BaseFormTypeQuery
{
/**
* @var Activity|null
*/
private $activityToIgnore;
private ?Activity $activityToIgnore = null;
/**
* @param Activity|int|array|null $activity
* @param Project|int|array|null $project
* @param Activity|array<Activity>|int|null $activity
* @param Project|array<Project>|int|null $project
*/
public function __construct($activity = null, $project = null)
public function __construct(Activity|array|int|null $activity = null, Project|array|int|null $project = null)
{
if (null !== $activity) {
if (!\is_array($activity)) {

View File

@@ -16,20 +16,16 @@ use App\Entity\Project;
*/
class ActivityQuery extends ProjectQuery
{
public const ACTIVITY_ORDER_ALLOWED = ['id', 'name', 'comment', 'customer', 'project', 'budget', 'timeBudget', 'visible'];
public const ACTIVITY_ORDER_ALLOWED = [
'name', 'description' => 'comment', 'customer', 'project', 'budget', 'timeBudget', 'visible'
];
/**
* @var array<Project|int>
* @var array<Project>
*/
private $projects = [];
/**
* @var bool
*/
private $globalsOnly = false;
/**
* @var bool
*/
private $excludeGlobals = false;
private array $projects = [];
private bool $globalsOnly = false;
private bool $excludeGlobals = false;
public function __construct()
{
@@ -42,77 +38,45 @@ class ActivityQuery extends ProjectQuery
]);
}
/**
* @return bool
*/
public function isGlobalsOnly(): bool
{
return (bool) $this->globalsOnly;
return $this->globalsOnly;
}
/**
* @param bool $globalsOnly
* @return self
*/
public function setGlobalsOnly($globalsOnly): self
public function setGlobalsOnly(bool $globalsOnly): self
{
$this->globalsOnly = (bool) $globalsOnly;
$this->globalsOnly = $globalsOnly;
return $this;
}
public function isExcludeGlobals(): bool
{
return (bool) $this->excludeGlobals;
return $this->excludeGlobals;
}
public function setExcludeGlobals(bool $excludeGlobals): self
{
$this->excludeGlobals = (bool) $excludeGlobals;
$this->excludeGlobals = $excludeGlobals;
return $this;
}
/**
* @return Project|int|null
* @deprecated since 1.9 - use getProjects() instead - will be removed with 2.0
*/
public function getProject()
{
if (\count($this->projects) > 0) {
return $this->projects[0];
}
return null;
}
/**
* @param Project|int|null $project
* @return self
* @deprecated since 1.9 - use setProjects() or addProject() instead - will be removed with 2.0
*/
public function setProject($project = null): self
{
if (null === $project) {
$this->projects = [];
} else {
$this->projects = [$project];
}
return $this;
}
/**
* @param Project|int $project
* @return self
*/
public function addProject($project): self
public function addProject(Project $project): self
{
$this->projects[] = $project;
return $this;
}
/**
* @param array<Project> $projects
* @return $this
*/
public function setProjects(array $projects): self
{
$this->projects = $projects;
@@ -120,11 +84,26 @@ class ActivityQuery extends ProjectQuery
return $this;
}
/**
* @return array<Project>
*/
public function getProjects(): array
{
return $this->projects;
}
/**
* @return array<int>
*/
public function getProjectIds(): array
{
return array_values(array_filter(array_unique(array_map(function (Project $project) {
return $project->getId();
}, $this->projects)), function ($id) {
return $id !== null;
}));
}
public function hasProjects(): bool
{
return !empty($this->projects);

View File

@@ -18,79 +18,39 @@ use App\Entity\User;
abstract class BaseFormTypeQuery
{
/**
* @var array
* @var array<Activity|int>
*/
private $activities = [];
private array $activities = [];
/**
* @var array
* @var array<Project|int>
*/
private $projects = [];
private array $projects = [];
/**
* @var array
* @var array<Customer|int>
*/
private $customers = [];
/**
* @var User
*/
private $user;
private array $customers = [];
private ?User $user = null;
/**
* @var array<Team>
*/
private $teams = [];
private array $teams = [];
/**
* @return Activity|int|null
* @deprecated since 1.9 - use getActivities() instead - will be removed with 2.0
*/
public function getActivity()
public function addActivity(Activity|int $activity): void
{
if (\count($this->activities) > 0) {
return $this->activities[0];
}
return null;
$this->activities[] = $activity;
}
/**
* @param Activity|int|null $activity
* @return self
* @deprecated since 1.9 - use setActivities() or addActivity() instead - will be removed with 2.0
* @param array<Activity|int> $activities
*/
public function setActivity($activity): self
{
if (null === $activity) {
$this->activities = [];
} else {
$this->activities = [$activity];
}
return $this;
}
/**
* @param Activity|int $activity
* @return self
*/
public function addActivity($activity): self
{
if (null !== $activity) {
$this->activities[] = $activity;
}
return $this;
}
/**
* @param Activity[]|int[] $activities
* @return self
*/
public function setActivities(array $activities): self
public function setActivities(array $activities): void
{
$this->activities = $activities;
return $this;
}
/**
* @return array<Activity|int>
*/
public function getActivities(): array
{
return $this->activities;
@@ -98,64 +58,24 @@ abstract class BaseFormTypeQuery
public function hasActivities(): bool
{
return !empty($this->activities);
return \count($this->activities) > 0;
}
/**
* @return Project|int|null
* @deprecated since 1.9 - use getProjects() instead - will be removed with 2.0
*/
public function getProject()
public function addProject(Project|int $project): void
{
if (\count($this->projects) > 0) {
return $this->projects[0];
}
return null;
$this->projects[] = $project;
}
/**
* @param Project|int|null $project
* @return self
* @deprecated since 1.9 - use addProject() instead - will be removed with 2.0
* @param array<Project|int> $projects
*/
public function setProject($project): self
{
if (null === $project) {
$this->projects = [];
} else {
$this->projects = [$project];
}
return $this;
}
/**
* @param Project|int $project
* @return self
*/
public function addProject($project): self
{
if (null !== $project) {
$this->projects[] = $project;
}
return $this;
}
/**
* @param Project[]|int[] $projects
* @return self
*/
public function setProjects(array $projects): self
public function setProjects(array $projects): void
{
$this->projects = $projects;
return $this;
}
/**
* @return array
* @return array<Project|int>
*/
public function getProjects(): array
{
@@ -164,60 +84,25 @@ abstract class BaseFormTypeQuery
public function hasProjects(): bool
{
return !empty($this->projects);
return \count($this->projects) > 0;
}
/**
* @return Customer|int|null
* @deprecated since 1.9 - use getCustomers() instead - will be removed with 2.0
* @param array<Customer|int> $customers
*/
public function getCustomer()
{
if (\count($this->customers) > 0) {
return $this->customers[0];
}
return null;
}
/**
* @param Customer|int|null $customer
* @return self
* @deprecated since 1.9 - use addCustomer() instead - will be removed with 2.0
*/
public function setCustomer($customer): self
{
if (null === $customer) {
$this->customers = [];
} else {
$this->customers = [$customer];
}
return $this;
}
/**
* @param Customer[]|int[] $customers
* @return self
*/
public function setCustomers(array $customers): self
public function setCustomers(array $customers): void
{
$this->customers = $customers;
}
return $this;
public function addCustomer(Customer|int $customer): void
{
$this->customers[] = $customer;
}
/**
* @param Customer|int $customer
* @return self
* @return array<Customer|int>
*/
public function addCustomer($customer): self
{
$this->customers[] = $customer;
return $this;
}
public function getCustomers(): array
{
return $this->customers;
@@ -225,7 +110,7 @@ abstract class BaseFormTypeQuery
public function hasCustomers(): bool
{
return !empty($this->customers);
return \count($this->customers) > 0;
}
public function getUser(): ?User
@@ -248,18 +133,15 @@ abstract class BaseFormTypeQuery
}
/**
* @param Team[] $teams
* @return self
* @param array<Team> $teams
*/
public function setTeams(array $teams): self
public function setTeams(array $teams): void
{
$this->teams = $teams;
return $this;
}
/**
* @return Team[]
* @return array<Team>
*/
public function getTeams(): array
{

View File

@@ -12,7 +12,9 @@ namespace App\Repository\Query;
use App\Entity\Bookmark;
use App\Entity\Team;
use App\Entity\User;
use App\Form\Model\DateRange;
use App\Utils\SearchTerm;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormErrorIterator;
/**
@@ -22,80 +24,33 @@ class BaseQuery
{
public const ORDER_ASC = 'ASC';
public const ORDER_DESC = 'DESC';
public const DEFAULT_PAGESIZE = 50;
/** @deprecated since 1.14 */
public const DEFAULT_PAGE = 1;
/**
* @deprecated since 1.4, will be removed with 2.0
*/
public const RESULT_TYPE_OBJECTS = 'Objects';
/**
* @deprecated since 1.4, will be removed with 2.0
*/
public const RESULT_TYPE_PAGER = 'PagerFanta';
/**
* @deprecated since 1.4, will be removed with 2.0
*/
public const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder';
private $defaults = [
/** @var array<string, string|int|null|bool|array<mixed>|DateRange> */
private array $defaults = [
'page' => 1,
'pageSize' => self::DEFAULT_PAGESIZE,
'orderBy' => 'id',
'order' => self::ORDER_ASC,
'searchTerm' => null,
];
/**
* @var int
*/
private $page = 1;
/**
* @var int
*/
private $pageSize = self::DEFAULT_PAGESIZE;
/**
* @var string
*/
private $orderBy = 'id';
/**
* @var string
*/
private $order = self::ORDER_ASC;
private int $page = 1;
private int $pageSize = self::DEFAULT_PAGESIZE;
private string $orderBy = 'id';
private string $order = self::ORDER_ASC;
/**
* @var array<string, string>
*/
private $orderGroups = [];
private array $orderGroups = [];
private ?User $currentUser = null;
/**
* @var string
* @deprecated since 1.4, will be removed with 2.0
* @var array<Team>
*/
private $resultType = self::RESULT_TYPE_PAGER;
/**
* @var User
*/
private $currentUser;
/**
* @var Team[]
*/
private $teams = [];
/**
* @var SearchTerm|null
*/
private $searchTerm;
/**
* @var Bookmark|null
*/
private $bookmark;
/**
* @var string|null
*/
private $name;
/**
* @var bool
*/
private $bookmarkSearch = false;
private array $teams = [];
private ?SearchTerm $searchTerm = null;
private ?Bookmark $bookmark = null;
private ?string $name = null;
private bool $bookmarkSearch = false;
/**
* @param Team[] $teams
@@ -140,29 +95,22 @@ class BaseQuery
* @param User $user
* @return self
*/
public function setCurrentUser(User $user)
public function setCurrentUser(?User $user): self
{
$this->currentUser = $user;
return $this;
}
/**
* @return int
*/
public function getPage()
public function getPage(): int
{
return $this->page;
}
/**
* @param int $page
* @return self
*/
public function setPage($page)
public function setPage(?int $page): self
{
if ($page !== null && (int) $page > 0) {
$this->page = (int) $page;
if ($page !== null && $page > 0) {
$this->page = $page;
}
return $this;
@@ -173,14 +121,10 @@ class BaseQuery
return $this->pageSize;
}
/**
* @param int $pageSize
* @return self
*/
public function setPageSize($pageSize)
public function setPageSize(?int $pageSize): self
{
if ($pageSize !== null && (int) $pageSize > 0) {
$this->pageSize = (int) $pageSize;
if ($pageSize !== null && $pageSize > 0) {
$this->pageSize = $pageSize;
}
return $this;
@@ -226,17 +170,6 @@ class BaseQuery
return $this->orderGroups;
}
/**
* @deprecated since 1.0
* @return string
*/
public function getResultType()
{
@trigger_error('BaseQuery::getResultType() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
return $this->resultType;
}
public function hasSearchTerm(): bool
{
return null !== $this->searchTerm;
@@ -247,18 +180,14 @@ class BaseQuery
return $this->searchTerm;
}
/**
* @param SearchTerm|null $searchTerm
* @return self
*/
public function setSearchTerm(?SearchTerm $searchTerm)
public function setSearchTerm(?SearchTerm $searchTerm): self
{
$this->searchTerm = $searchTerm;
return $this;
}
protected function set($name, $value)
protected function set(string $name, mixed $value): void
{
$method = 'set' . ucfirst($name);
if (method_exists($this, $method)) {
@@ -267,7 +196,7 @@ class BaseQuery
return;
}
if (substr($name, -1) === 's') {
if (str_ends_with($name, 's')) {
$method = 'add' . ucfirst(substr($name, 0, \strlen($name) - 1));
if (method_exists($this, $method) && \is_array($value)) {
foreach ($value as $v) {
@@ -283,7 +212,7 @@ class BaseQuery
}
}
protected function get($name)
protected function get(string $name): mixed
{
$methods = ['get' . ucfirst($name), 'is' . ucfirst($name), 'has' . ucfirst($name)];
foreach ($methods as $method) {
@@ -295,15 +224,17 @@ class BaseQuery
if (property_exists($this, $name)) {
return $this->{$name};
}
return null;
}
/**
* You have to add ALL user facing form fields as default!
*
* @param array $defaults
* @param array<string, string|int|null|bool|array<mixed>|DateRange> $defaults
* @return self
*/
protected function setDefaults(array $defaults)
protected function setDefaults(array $defaults): self
{
$this->defaults = array_merge($this->defaults, $defaults);
foreach ($this->defaults as $key => $value) {
@@ -314,14 +245,14 @@ class BaseQuery
}
/**
* @param FormErrorIterator $errors
* @return self
* @param FormErrorIterator<FormError> $errors
* @return $this
*/
public function resetByFormError(FormErrorIterator $errors)
public function resetByFormError(FormErrorIterator $errors): self
{
foreach ($errors as $error) {
$key = $error->getOrigin()->getName();
if (\array_key_exists($key, $this->defaults)) {
$key = $error->getOrigin()?->getName();
if ($key !== null && \array_key_exists($key, $this->defaults)) {
$this->set($key, $this->defaults[$key]);
}
}
@@ -399,7 +330,7 @@ class BaseQuery
$currentValue = $this->get($filter);
if (\is_object($currentValue)) {
if ($currentValue != $expectedValue) {
if ($currentValue !== $expectedValue) {
return false;
}
} else {

View File

@@ -11,10 +11,7 @@ namespace App\Repository\Query;
trait BillableTrait
{
/**
* @var bool|null
*/
private $billable = null;
private ?bool $billable = null;
public function getBillable(): ?bool
{

View File

@@ -16,16 +16,13 @@ use App\Entity\Customer;
*/
final class CustomerFormTypeQuery extends BaseFormTypeQuery
{
/**
* @var Customer|null
*/
private $customerToIgnore;
private $allowCustomerPreselect = false;
private ?Customer $customerToIgnore = null;
private bool $allowCustomerPreselect = false;
/**
* @param Customer|int|null $customer
* @param Customer|array<Customer>|int|null $customer
*/
public function __construct($customer = null)
public function __construct(Customer|array|int|null $customer = null)
{
if (null !== $customer) {
if (!\is_array($customer)) {

View File

@@ -9,15 +9,12 @@
namespace App\Repository\Query;
/**
* Can be used for advanced queries with the: CustomerRepository
*/
class CustomerQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
public const CUSTOMER_ORDER_ALLOWED = [
'id', 'name', 'comment', 'country', 'number', 'homepage', 'email', 'mobile', 'fax',
'name', 'description' => 'comment', 'country', 'number', 'homepage', 'email', 'mobile', 'fax',
'phone', 'currency', 'address', 'contact', 'company', 'vat_id', 'budget', 'timeBudget', 'visible'
];

View File

@@ -13,18 +13,11 @@ use App\Form\Model\DateRange;
trait DateRangeTrait
{
/**
* @var DateRange
*/
protected $dateRange;
protected ?DateRange $dateRange = null;
public function getBegin(): ?\DateTime
{
if (null === $this->dateRange) {
return null;
}
return $this->dateRange->getBegin();
return $this->dateRange?->getBegin();
}
public function setBegin(\DateTime $begin): void
@@ -34,11 +27,7 @@ trait DateRangeTrait
public function getEnd(): ?\DateTime
{
if (null === $this->dateRange) {
return null;
}
return $this->dateRange->getEnd();
return $this->dateRange?->getEnd();
}
public function setEnd(\DateTime $end): void

View File

@@ -11,22 +11,16 @@ namespace App\Repository\Query;
class ExportQuery extends TimesheetQuery
{
/**
* @var string
*/
private $renderer;
/**
* @var bool
*/
private $markAsExported = false;
private ?string $renderer = null;
private bool $markAsExported = true;
public function __construct()
{
parent::__construct();
$this->setDefaults([
'order' => ExportQuery::ORDER_ASC,
'state' => ExportQuery::STATE_STOPPED,
'exported' => ExportQuery::STATE_NOT_EXPORTED,
'order' => BaseQuery::ORDER_ASC,
'state' => TimesheetQuery::STATE_STOPPED,
'exported' => TimesheetQuery::STATE_NOT_EXPORTED,
]);
}
@@ -47,8 +41,11 @@ class ExportQuery extends TimesheetQuery
return $this->markAsExported;
}
public function setMarkAsExported(bool $markAsExported): ExportQuery
public function setMarkAsExported(?bool $markAsExported): ExportQuery
{
if ($markAsExported === null) {
$markAsExported = false;
}
$this->markAsExported = $markAsExported;
return $this;

View File

@@ -30,11 +30,11 @@ class InvoiceArchiveQuery extends BaseQuery
* Filter for invoice status (by default all)
* @var string[]
*/
private $status = [];
private array $status = [];
/**
* @var Customer[]
*/
private $customers = [];
private array $customers = [];
public function __construct()
{

View File

@@ -9,31 +9,29 @@
namespace App\Repository\Query;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
/**
* Find items (eg timesheets) for creating a new invoice.
* Find items (e.g. timesheets) for creating a new invoice.
*/
class InvoiceQuery extends TimesheetQuery
{
/**
* @var InvoiceTemplate
*/
private $template;
/**
* @var bool
*/
private $markAsExported = true;
private ?InvoiceTemplate $template = null;
private ?\DateTime $invoiceDate = null;
private bool $allowTemplateOverwrite = true;
public function __construct()
{
parent::__construct();
$this->setDefaults([
'order' => InvoiceQuery::ORDER_ASC,
'exported' => InvoiceQuery::STATE_NOT_EXPORTED,
'order' => self::ORDER_ASC,
'exported' => self::STATE_NOT_EXPORTED,
'state' => self::STATE_STOPPED,
'billable' => true,
'markAsExported' => true,
'invoiceDate' => null,
]);
}
@@ -49,15 +47,68 @@ class InvoiceQuery extends TimesheetQuery
return $this;
}
public function isMarkAsExported(): bool
/**
* Helper method, because many templates access {{ model.query.customer }} directly.
*
* @return Customer|null
*/
public function getCustomer(): ?Customer
{
return $this->markAsExported;
$customers = $this->getCustomers();
if (\count($customers) === 1) {
return $customers[0];
}
return null;
}
public function setMarkAsExported(bool $markAsExported): InvoiceQuery
/**
* Helper method, because many templates access {{ model.query.project }} directly.
*
* @return Project|null
*/
public function getProject(): ?Project
{
$this->markAsExported = $markAsExported;
$projects = $this->getProjects();
if (\count($projects) === 1) {
return $projects[0];
}
return $this;
return null;
}
/**
* Helper method, because many templates access {{ model.query.activity }} directly.
*
* @return Activity|null
*/
public function getActivity(): ?Activity
{
$activities = $this->getActivities();
if (\count($activities) === 1) {
return $activities[0];
}
return null;
}
public function getInvoiceDate(): ?\DateTime
{
return $this->invoiceDate;
}
public function setInvoiceDate(?\DateTime $invoiceDate): void
{
$this->invoiceDate = $invoiceDate;
}
public function isAllowTemplateOverwrite(): bool
{
return $this->allowTemplateOverwrite;
}
public function setAllowTemplateOverwrite(bool $allowTemplateOverwrite): void
{
$this->allowTemplateOverwrite = $allowTemplateOverwrite;
}
}

View File

@@ -14,26 +14,17 @@ use App\Entity\Project;
final class ProjectFormTypeQuery extends BaseFormTypeQuery
{
/**
* @var \DateTime|null
*/
private $projectStart;
/**
* @var \DateTime|null
*/
private $projectEnd;
/**
* @var Project|null
*/
private $projectToIgnore;
private $ignoreDate = false;
private $withCustomer = false;
private ?\DateTime $projectStart = null;
private ?\DateTime $projectEnd = null;
private ?Project $projectToIgnore = null;
private bool $ignoreDate = false;
private bool $withCustomer = false;
/**
* @param Project|int|null|array<int>|array<Project> $project
* @param Customer|int|null|array<int>|array<Customer> $customer
* @param Project|array<Project>|int|null $project
* @param Customer|array<Customer>|int|null $customer
*/
public function __construct($project = null, $customer = null)
public function __construct(Project|array|int|null $project = null, Customer|array|int|null $customer = null)
{
if (null !== $project) {
if (!\is_array($project)) {
@@ -49,13 +40,12 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
$this->setCustomers($customer);
}
$this->projectStart = $this->projectEnd = new \DateTime();
$this->projectStart = new \DateTime();
$this->projectEnd = clone $this->projectStart;
}
/**
* Whether customers should be joined
*
* @return bool
*/
public function withCustomer(): bool
{
@@ -64,17 +54,12 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
/**
* Directly join the customer
*
* @param bool $withCustomer
*/
public function setWithCustomer(bool $withCustomer): void
{
$this->withCustomer = $withCustomer;
}
/**
* @return Project|null
*/
public function getProjectToIgnore(): ?Project
{
return $this->projectToIgnore;

View File

@@ -11,33 +11,22 @@ namespace App\Repository\Query;
use App\Entity\Customer;
/**
* Can be used for advanced queries with the: ProjectRepository
*/
class ProjectQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
public const PROJECT_ORDER_ALLOWED = [
'id', 'name', 'comment', 'customer', 'orderNumber', 'orderDate', 'project_start', 'project_end', 'budget', 'timeBudget', 'visible'
'name', 'description' => 'comment', 'customer', 'orderNumber', 'orderDate',
'project_start', 'project_end', 'budget', 'timeBudget', 'visible'
];
/**
* @var array<Customer|int>
* @var array<Customer>
*/
private $customers = [];
/**
* @var \DateTime|null
*/
private $projectStart;
/**
* @var \DateTime|null
*/
private $projectEnd;
/**
* @var null|bool
*/
private $globalActivities = null;
private array $customers = [];
private ?\DateTime $projectStart = null;
private ?\DateTime $projectEnd = null;
private ?bool $globalActivities = null;
public function __construct()
{
@@ -51,46 +40,17 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
]);
}
/**
* @return Customer|int|null
* @deprecated since 1.9 - use getCustomers() instead - will be removed with 2.0
*/
public function getCustomer()
{
if (\count($this->customers) > 0) {
return $this->customers[0];
}
return null;
}
/**
* @param Customer|int|null $customer
* @return $this
* @deprecated since 1.9 - use setCustomers() or addCustomer() instead - will be removed with 2.0
*/
public function setCustomer($customer = null)
{
if (null === $customer) {
$this->customers = [];
} else {
$this->customers = [$customer];
}
return $this;
}
/**
* @param Customer|int $customer
* @return $this
*/
public function addCustomer($customer)
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;
@@ -98,11 +58,26 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
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

@@ -11,7 +11,7 @@ namespace App\Repository\Query;
class TagQuery extends BaseQuery
{
public const TAG_ORDER_ALLOWED = ['id', 'name', 'amount'];
public const TAG_ORDER_ALLOWED = ['name', 'amount'];
public function __construct()
{

View File

@@ -13,12 +13,12 @@ use App\Entity\User;
class TeamQuery extends BaseQuery
{
public const TEAM_ORDER_ALLOWED = ['id', 'name'];
public const TEAM_ORDER_ALLOWED = ['name'];
/**
* @var User[]
*/
private $users = [];
private array $users = [];
public function __construct()
{

View File

@@ -14,9 +14,6 @@ use App\Entity\Tag;
use App\Entity\User;
use App\Form\Model\DateRange;
/**
* Can be used for advanced timesheet repository queries.
*/
class TimesheetQuery extends ActivityQuery implements BillableInterface
{
use BillableTrait;
@@ -30,38 +27,21 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface
public const TIMESHEET_ORDER_ALLOWED = ['begin', 'end', 'duration', 'rate', 'hourlyRate', 'customer', 'project', 'activity', 'description'];
private ?User $timesheetUser = null;
/** @var array<Activity> */
private array $activities = [];
private int $state = self::STATE_ALL;
private int $exported = self::STATE_ALL;
private ?int $maxResults = null;
private ?\DateTime $modifiedAfter = null;
/**
* @var User|null
* @var array<Tag>
*/
protected $timesheetUser;
private array $tags = [];
/**
* @var array
* @var array<User>
*/
private $activities = [];
/**
* @var int
*/
protected $state = self::STATE_ALL;
/**
* @var int
*/
protected $exported = self::STATE_ALL;
/**
* @var \DateTime|null
*/
private $modifiedAfter;
/**
* @var iterable
*/
protected $tags = [];
/**
* @var User[]
*/
private $users = [];
/**
* @var int|null
*/
private $maxResults;
private array $users = [];
public function __construct(bool $resetTimes = true)
{
@@ -114,67 +94,42 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface
}
/**
* Limit the data exclusively to the user (eg. users own timesheets).
*
* @return User|int|null
* Limit the data exclusively to the user.
*/
public function getUser()
public function getUser(): ?User
{
return $this->timesheetUser;
}
/**
* Limit the data exclusively to the user (eg. users own timesheets).
*
* @param User|int|null $user
* @return TimesheetQuery
* Limit the data exclusively to the user.
*/
public function setUser($user = null)
public function setUser(?User $user): void
{
$this->timesheetUser = $user;
return $this;
}
/**
* @return Activity|int|null
* @deprecated since 1.9 - use getActivities() instead - will be removed with 2.0
* @return array<int>
*/
public function getActivity()
public function getActivityIds(): array
{
if (\count($this->activities) > 0) {
return $this->activities[0];
}
return null;
return array_values(array_filter(array_unique(array_map(function (Activity $activity) {
return $activity->getId();
}, $this->activities)), function ($id) {
return $id !== null;
}));
}
/**
* @return array<Activity>
*/
public function getActivities(): array
{
return $this->activities;
}
/**
* @param Activity|int|null $activity
* @return $this
* @deprecated since 1.9 - use setActivities() or addActivity() instead - will be removed with 2.0
*/
public function setActivity($activity)
{
if (null === $activity) {
$this->activities = [];
} else {
$this->activities = [$activity];
}
return $this;
}
/**
* @param Activity|int $activity
* @return $this
*/
public function addActivity($activity): TimesheetQuery
public function addActivity(Activity $activity): TimesheetQuery
{
$this->activities[] = $activity;
@@ -182,8 +137,7 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface
}
/**
* @param Activity[]|int[] $activities
* @return $this
* @param array<Activity> $activities
*/
public function setActivities(array $activities): TimesheetQuery
{
@@ -214,7 +168,6 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface
public function setState(int $state): TimesheetQuery
{
$state = (int) $state;
if (\in_array($state, [self::STATE_ALL, self::STATE_RUNNING, self::STATE_STOPPED], true)) {
$this->state = $state;
}
@@ -237,39 +190,33 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface
return $this->exported === self::STATE_NOT_EXPORTED;
}
public function setExported(int $exported): TimesheetQuery
public function setExported(int $exported): void
{
$exported = (int) $exported;
if (\in_array($exported, [self::STATE_ALL, self::STATE_EXPORTED, self::STATE_NOT_EXPORTED], true)) {
$this->exported = $exported;
if (!\in_array($exported, [self::STATE_ALL, self::STATE_EXPORTED, self::STATE_NOT_EXPORTED], true)) {
throw new \InvalidArgumentException('Unknown export state given');
}
return $this;
$this->exported = $exported;
}
public function getTags(bool $allowUnknown = false): iterable
/**
* @return array<Tag>
*/
public function getTags(): array
{
if (empty($this->tags)) {
return [];
}
$result = [];
foreach ($this->tags as $tag) {
if (!$allowUnknown && $tag instanceof Tag && null === $tag->getId()) {
continue;
}
$result[] = $tag;
}
return $result;
return array_values($this->tags);
}
public function setTags(iterable $tags): TimesheetQuery
public function removeTag(Tag $tag): void
{
$this->tags = $tags;
if (isset($this->tags[$tag->getId()])) {
unset($this->tags[$tag->getId()]);
}
}
return $this;
public function addTag(Tag $tag): void
{
$this->tags[$tag->getId()] = $tag;
}
public function getModifiedAfter(): ?\DateTime

View File

@@ -21,23 +21,20 @@ final class UserFormTypeQuery extends BaseFormTypeQuery
/**
* @var User[]
*/
private $includeUsers = [];
private array $includeUsers = [];
/**
* @var User[]
*/
private $ignoredUsers = [];
private array $ignoredUsers = [];
/**
* Sets a list of users which must be included in the result always.
*
* @param array $users
* @return UserFormTypeQuery
* @param array<User> $users
*/
public function setUsersAlwaysIncluded(array $users): UserFormTypeQuery
public function setUsersAlwaysIncluded(array $users): void
{
$this->includeUsers = $users;
return $this;
}
/**

View File

@@ -18,23 +18,22 @@ class UserQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
public const USER_ORDER_ALLOWED = ['id', 'alias', 'username', 'title', 'email'];
public const USER_ORDER_ALLOWED = ['alias', 'user', 'username', 'title', 'email'];
/**
* @var string|null
*/
private $role;
private ?string $role = null;
/**
* @var Team[]
*/
private $searchTeams = [];
private array $searchTeams = [];
private ?bool $systemAccount = null;
public function __construct()
{
$this->setDefaults([
'orderBy' => 'username',
'orderBy' => 'user',
'searchTeams' => [],
'visibility' => VisibilityInterface::SHOW_VISIBLE,
'systemAccount' => null,
]);
}
@@ -65,4 +64,14 @@ class UserQuery extends BaseQuery implements VisibilityInterface
return $this;
}
public function getSystemAccount(): ?bool
{
return $this->systemAccount;
}
public function setSystemAccount(?bool $systemAccount): void
{
$this->systemAccount = $systemAccount;
}
}

View File

@@ -23,9 +23,5 @@ interface VisibilityInterface
public function getVisibility(): int;
/**
* @param int $visibility
* @return mixed
*/
public function setVisibility($visibility);
public function setVisibility(int $visibility): void;
}

View File

@@ -1,20 +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\Query;
/**
* Query class for Repositories with a visibility field.
*
* @deprecated since 1.7, will be removed with 2.0
*/
class VisibilityQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
}

View File

@@ -11,24 +11,19 @@ namespace App\Repository\Query;
trait VisibilityTrait
{
/**
* @var int
*/
private $visibility = VisibilityInterface::SHOW_VISIBLE;
private int $visibility = VisibilityInterface::SHOW_VISIBLE;
public function getVisibility(): int
{
return $this->visibility;
}
public function setVisibility($visibility)
public function setVisibility(int $visibility): void
{
$visibility = (int) $visibility;
if (\in_array($visibility, VisibilityInterface::ALLOWED_VISIBILITY_STATES, true)) {
$this->visibility = $visibility;
if (!\in_array($visibility, VisibilityInterface::ALLOWED_VISIBILITY_STATES, true)) {
throw new \InvalidArgumentException('Unknown visibility given');
}
return $this;
$this->visibility = $visibility;
}
public function isShowHidden(): bool

View File

@@ -9,31 +9,45 @@
namespace App\Repository\Result;
use App\Entity\Timesheet;
use App\Repository\Loader\TimesheetLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Query\TimesheetQuery;
use App\Utils\Pagination;
use Doctrine\ORM\QueryBuilder;
class TimesheetResult
final class TimesheetResult
{
private $queryBuilder;
private ?TimesheetResultStatistic $statisticCache = null;
private bool $cachedFullyHydrated = false;
/**
* @var array<Timesheet>|null
*/
private ?array $resultCache = null;
public function __construct(QueryBuilder $queryBuilder)
/**
* @internal
*/
public function __construct(private TimesheetQuery $query, private QueryBuilder $queryBuilder)
{
$this->queryBuilder = $queryBuilder;
}
public function getStatistic(): TimesheetResultStatistic
{
$qb = clone $this->queryBuilder;
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select('COUNT(t.id) as counter')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
;
if ($this->statisticCache === null) {
$qb = clone $this->queryBuilder;
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select('COUNT(t.id) as counter')
->addSelect('COALESCE(SUM(t.duration), 0) as duration');
$result = $qb->getQuery()->getArrayResult()[0];
$result = $qb->getQuery()->getArrayResult()[0];
return new TimesheetResultStatistic($result['counter'], $result['duration']);
$this->statisticCache = new TimesheetResultStatistic($result['counter'], $result['duration']);
}
return $this->statisticCache;
}
public function toIterable(): iterable
@@ -43,14 +57,35 @@ class TimesheetResult
return $query->toIterable();
}
/**
* @param bool $fullyHydrated
* @return array<Timesheet>
*/
public function getResults(bool $fullyHydrated = false): array
{
$query = $this->queryBuilder->getQuery();
$results = $query->getResult();
if ($this->resultCache === null || ($fullyHydrated && $this->cachedFullyHydrated === false)) {
$query = $this->queryBuilder->getQuery();
$results = $query->getResult();
$loader = new TimesheetLoader($this->queryBuilder->getEntityManager(), $fullyHydrated);
$loader->loadResults($results);
$loader = new TimesheetLoader($this->queryBuilder->getEntityManager(), $fullyHydrated);
$loader->loadResults($results);
return $results;
$this->cachedFullyHydrated = $fullyHydrated;
$this->resultCache = $results;
}
return $this->resultCache;
}
public function getPagerfanta(bool $fullyHydrated = false): Pagination
{
$qb = clone $this->queryBuilder;
$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());
return $paginator;
}
}

View File

@@ -9,15 +9,10 @@
namespace App\Repository\Result;
class TimesheetResultStatistic
final class TimesheetResultStatistic
{
private $count = 0;
private $duration = 0;
public function __construct(int $count, int $duration)
public function __construct(private int $count, private int $duration)
{
$this->count = $count;
$this->duration = $duration;
}
public function getCount(): int

View File

@@ -25,18 +25,21 @@ class RolePermissionRepository extends EntityRepository
$entityManager->flush();
}
public function findRolePermission(Role $role, string $permission)
public function findRolePermission(Role $role, string $permission): ?RolePermission
{
return $this->findOneBy(['role' => $role, 'permission' => $permission]);
}
public function getAllAsArray()
/**
* @return array<array<string, string|bool>>
*/
public function getAllAsArray(): array
{
$qb = $this->createQueryBuilder('rp');
$qb->select('r.name as role,rp.permission,rp.allowed')
->leftJoin('rp.role', 'r');
return $qb->getQuery()->getArrayResult();
return $qb->getQuery()->getArrayResult(); // @phpstan-ignore-line
}
}

View File

@@ -11,7 +11,7 @@ namespace App\Repository;
use App\Entity\Role;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Exception\ORMException;
/**
* @extends \Doctrine\ORM\EntityRepository<Role>

View File

@@ -13,16 +13,21 @@ use App\Entity\Tag;
use App\Repository\Paginator\QueryBuilderPaginator;
use App\Repository\Query\TagFormTypeQuery;
use App\Repository\Query\TagQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
/**
* @extends \Doctrine\ORM\EntityRepository<Tag>
*/
class TagRepository extends EntityRepository
{
/**
* See KimaiFormSelect.js (maxOptions) as well.
*/
public const MAX_AMOUNT_SELECT = 500;
/**
* @param Tag $tag
* @throws ORMException
@@ -47,6 +52,15 @@ class TagRepository extends EntityRepository
$entityManager->flush();
}
/**
* @param array $tagNames
* @return array<Tag>
*/
public function findTagsByName(array $tagNames): array
{
return $this->findBy(['name' => $tagNames]);
}
public function findTagByName(string $tagName): ?Tag
{
return $this->findOneBy(['name' => $tagName]);
@@ -57,7 +71,7 @@ class TagRepository extends EntityRepository
* @param string $tagNames
* @return array
*/
public function findIdsByTagNameList(string $tagNames)
public function findIdsByTagNameList(string $tagNames): array
{
$qb = $this
->createQueryBuilder('t')
@@ -80,7 +94,7 @@ class TagRepository extends EntityRepository
* @param string $filter
* @return array
*/
public function findAllTagNames($filter = null)
public function findAllTagNames($filter = null): array
{
$qb = $this->createQueryBuilder('t');
@@ -104,9 +118,9 @@ class TagRepository extends EntityRepository
* - amount
*
* @param TagQuery $query
* @return Pagerfanta
* @return Pagination
*/
public function getTagCount(TagQuery $query)
public function getTagCount(TagQuery $query): Pagination
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
@@ -120,30 +134,24 @@ class TagRepository extends EntityRepository
$paginator = new QueryBuilderPaginator($qb, $counter);
$pagerfanta = new Pagerfanta($paginator);
$pagerfanta->setMaxPerPage($query->getPageSize());
$pagerfanta->setCurrentPage($query->getPage());
$pager = new Pagination($paginator);
$pager->setMaxPerPage($query->getPageSize());
$pager->setCurrentPage($query->getPage());
return $pagerfanta;
return $pager;
}
private function getQueryBuilderForQuery(TagQuery $query): QueryBuilder
{
$qb = $this->createQueryBuilder('tag');
$qb
->select('tag.id, tag.name, tag.color, SIZE(tag.timesheets) as amount')
;
$qb->select('tag.id, tag.name, tag.color, SIZE(tag.timesheets) as amount');
$orderBy = $query->getOrderBy();
switch ($orderBy) {
case 'amount':
$orderBy = 'amount';
break;
default:
$orderBy = 'tag.' . $orderBy;
break;
}
$orderBy = match ($orderBy) {
'amount' => 'amount',
default => 'tag.' . $orderBy,
};
$qb->addOrderBy($orderBy, $query->getOrder());

View File

@@ -17,10 +17,10 @@ use App\Repository\Loader\TeamLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TeamQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
/**
* @extends \Doctrine\ORM\EntityRepository<Team>
@@ -40,7 +40,7 @@ class TeamRepository extends EntityRepository
return $result;
}
public function find($id, $lockMode = null, $lockVersion = null)
public function find($id, $lockMode = null, $lockVersion = null): ?Team
{
/** @var Team|null $team */
$team = parent::find($id, $lockMode, $lockVersion);
@@ -127,9 +127,9 @@ class TeamRepository extends EntityRepository
return $qb;
}
public function getPagerfantaForQuery(TeamQuery $query): Pagerfanta
public function getPagerfantaForQuery(TeamQuery $query): Pagination
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator = new Pagination($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());

View File

@@ -9,26 +9,20 @@
namespace App\Repository;
use App\Entity\ExportableItem;
use App\Entity\Timesheet;
use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Repository\Query\InvoiceQuery;
final class TimesheetInvoiceItemRepository implements InvoiceItemRepositoryInterface
{
/**
* @var TimesheetRepository
*/
private $repository;
public function __construct(TimesheetRepository $repository)
public function __construct(private TimesheetRepository $repository)
{
$this->repository = $repository;
}
/**
* @param InvoiceQuery $query
* @return InvoiceItemInterface[]
* @return ExportableItem[]
*/
public function getInvoiceItemsForQuery(InvoiceQuery $query): iterable
{
@@ -36,9 +30,9 @@ final class TimesheetInvoiceItemRepository implements InvoiceItemRepositoryInter
}
/**
* @param InvoiceItemInterface[] $invoiceItems
* @param ExportableItem[] $invoiceItems
*/
public function setExported(array $invoiceItems)
public function setExported(array $invoiceItems): void
{
$timesheets = [];

View File

@@ -18,15 +18,14 @@ use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Model\Statistic\Month;
use App\Model\Statistic\Year;
use App\Model\Revenue;
use App\Model\TimesheetStatistic;
use App\Repository\Loader\TimesheetLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Result\TimesheetResult;
use App\Utils\Pagination;
use DateInterval;
use DateTime;
use Doctrine\DBAL\Types\Types;
@@ -35,7 +34,6 @@ use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Exception;
use InvalidArgumentException;
use Pagerfanta\Pagerfanta;
/**
* @extends \Doctrine\ORM\EntityRepository<Timesheet>
@@ -49,10 +47,6 @@ class TimesheetRepository extends EntityRepository
public const STATS_QUERY_USER = 'users';
public const STATS_QUERY_AMOUNT = 'amount';
public const STATS_QUERY_ACTIVE = 'active';
/**
* @deprecated since 1.15 - use TimesheetStatisticService::getMonthlyStats() instead - will be removed with 2.0
*/
public const STATS_QUERY_MONTHLY = 'monthly';
/**
* Fetches the raw data of a timesheet, to allow comparison e.g. of submitted and previously stored data.
@@ -90,7 +84,7 @@ class TimesheetRepository extends EntityRepository
* @param null $lockVersion
* @return Timesheet|null
*/
public function find($id, $lockMode = null, $lockVersion = null)
public function find($id, $lockMode = null, $lockVersion = null): ?Timesheet
{
/** @var Timesheet|null $timesheet */
$timesheet = parent::find($id, $lockMode, $lockVersion);
@@ -104,12 +98,7 @@ class TimesheetRepository extends EntityRepository
return $timesheet;
}
/**
* @param Timesheet $timesheet
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function delete(Timesheet $timesheet)
public function delete(Timesheet $timesheet): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($timesheet);
@@ -137,51 +126,23 @@ class TimesheetRepository extends EntityRepository
}
}
/**
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
* @codeCoverageIgnore
*/
public function add(Timesheet $timesheet, int $maxRunningEntries)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
if (null === $timesheet->getEnd()) {
$this->stopActiveEntries($timesheet->getUser(), $maxRunningEntries, false);
}
$em->persist($timesheet);
$em->flush();
$em->commit();
} catch (Exception $ex) {
$em->rollback();
throw $ex;
}
}
public function begin()
public function begin(): void
{
$this->getEntityManager()->beginTransaction();
}
public function commit()
public function commit(): void
{
$this->getEntityManager()->flush();
$this->getEntityManager()->commit();
}
public function rollback()
public function rollback(): void
{
$this->getEntityManager()->rollback();
}
/**
* @param Timesheet $timesheet
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function save(Timesheet $timesheet)
public function save(Timesheet $timesheet): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($timesheet);
@@ -210,65 +171,23 @@ class TimesheetRepository extends EntityRepository
}
/**
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
* @codeCoverageIgnore
*
* @param Timesheet $entry
* @param bool $flush
* @return bool
* @throws RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function stopRecording(Timesheet $entry, bool $flush = true)
{
if (null !== $entry->getEnd()) {
throw new RepositoryException('Timesheet entry already stopped');
}
// seems to be necessary so Doctrine will recognize a changed timestamp
$begin = clone $entry->getBegin();
$end = new DateTime('now', $begin->getTimezone());
$entry->setBegin($begin);
$entry->setEnd($end);
$entityManager = $this->getEntityManager();
$entityManager->persist($entry);
if ($flush) {
$entityManager->flush();
}
return true;
}
/**
* @param string $type
* @param self::STATS_QUERY_* $type
* @param DateTime|null $begin
* @param DateTime|null $end
* @param User|null $user
* @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getStatistic(string $type, ?DateTime $begin, ?DateTime $end, ?User $user, ?bool $billable = null)
public function getStatistic(string $type, ?DateTime $begin, ?DateTime $end, ?User $user, ?bool $billable = null): mixed
{
switch ($type) {
case self::STATS_QUERY_ACTIVE:
return \count($this->getActiveEntries($user));
case self::STATS_QUERY_MONTHLY:
return $this->getMonthlyStats($begin, $end, $user);
case 'daily':
return $this->getDailyStats($user, $begin, $end);
case self::STATS_QUERY_DURATION:
$what = 'COALESCE(SUM(t.duration), 0)';
break;
case self::STATS_QUERY_RATE:
$what = 'COALESCE(SUM(t.rate), 0)';
$billable = true;
break;
return $this->getRevenue($begin, $end, $user);
case self::STATS_QUERY_USER:
$what = 'COUNT(DISTINCT(t.user))';
break;
@@ -282,6 +201,46 @@ class TimesheetRepository extends EntityRepository
return $this->queryTimeRange($what, $begin, $end, $user, $billable);
}
/**
* @param DateTime|null $begin
* @param DateTime|null $end
* @param User|null $user
* @return array<Revenue>
*/
public function getRevenue(?DateTime $begin, ?DateTime $end, ?User $user): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->addSelect('COALESCE(SUM(t.rate), 0) as revenue')
->addSelect('c.currency as currency')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
->groupBy('c.currency')
;
// TODO only billable?
if ($begin !== null) {
$qb->andWhere($qb->expr()->between('t.begin', ':from', ':to'))
->setParameter('from', $begin)
->setParameter('to', $end);
}
if ($user !== null) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
$all = [];
foreach ($qb->getQuery()->getArrayResult() as $item) {
$all[] = new Revenue($item['currency'], $item['revenue']);
}
return $all;
}
/**
* @param string|string[] $select
* @param DateTime|null $begin
@@ -291,7 +250,7 @@ class TimesheetRepository extends EntityRepository
* @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function queryTimeRange($select, ?DateTime $begin, ?DateTime $end, ?User $user, ?bool $billable = null)
protected function queryTimeRange(string|array $select, ?DateTime $begin, ?DateTime $end, ?User $user, ?bool $billable = null): mixed
{
$selects = $select;
if (!\is_array($select)) {
@@ -328,22 +287,17 @@ class TimesheetRepository extends EntityRepository
}
if (\is_array($select)) {
/* @phpstan-ignore-next-line */
/* @phpstan-ignore-next-line */
return $qb->getQuery()->getOneOrNullResult();
}
/** @phpstan-ignore-next-line */
/* @phpstan-ignore-next-line */
$result = $qb->getQuery()->getSingleScalarResult();
return empty($result) ? 0 : $result;
}
/**
* @param User $user
* @param bool $bcSafe will be removed with 2.0
* @return TimesheetStatistic
*/
public function getUserStatistics(User $user, bool $bcSafe = true): TimesheetStatistic
public function getUserStatistics(User $user): TimesheetStatistic
{
$stats = new TimesheetStatistic();
@@ -357,8 +311,10 @@ class TimesheetRepository extends EntityRepository
$stats->setDurationTotal($allTimeData['duration']);
$stats->setRecordsTotal($allTimeData['amount']);
$billableAllTime = $this->getStatistic(self::STATS_QUERY_RATE, null, null, $user, true);
$stats->setRateTotalBillable($billableAllTime);
$data = $this->getRevenue(null, null, $user);
foreach ($data as $row) {
$stats->setRateTotalBillable($stats->getRateTotalBillable() + $row->getAmount());
}
$timezone = new \DateTimeZone($user->getTimezone());
$begin = new DateTime('first day of this month 00:00:00', $timezone);
@@ -377,341 +333,25 @@ class TimesheetRepository extends EntityRepository
$stats->setAmountThisMonth($monthData['rate']);
$stats->setDurationThisMonth($monthData['duration']);
$billableMonth = $this->getStatistic(self::STATS_QUERY_RATE, $begin, $end, $user, true);
$stats->setRateThisMonthBillable($billableMonth);
if ($bcSafe) {
$firstEntry = $this->getEntityManager()
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
->setParameter('user', $user)
->getSingleScalarResult();
$timezone = new \DateTimeZone($user->getTimezone());
if ($firstEntry !== null) {
$stats->setFirstEntry(new DateTime($firstEntry, $timezone));
} else {
@trigger_error(
'TimesheetStatistic::getFirstEntry() returns a wrong result for users without record and will be removed with 2.0',
E_USER_DEPRECATED
);
$stats->setFirstEntry(new DateTime('now', $timezone));
}
$data = $this->getRevenue($begin, $end, $user);
foreach ($data as $row) {
$stats->setRateThisMonthBillable($stats->getRateThisMonthBillable() + $row->getAmount());
}
return $stats;
}
/**
* @deprecated since 1.15 - use TimesheetStatisticService::getMonthlyStats() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param DateTime $begin
* @param DateTime $end
* @param User|null $user
* @return Year[]
* @param bool $ticktac
* @return Timesheet[]
*/
public function getMonthlyStats(DateTime $begin, DateTime $end, ?User $user = null): array
{
@trigger_error('TimesheetRepository::getMonthlyStats() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
/** @var Year[] $years */
$years = [];
$tmp = clone $begin;
while ($tmp < $end) {
$curYear = $tmp->format('Y');
if (!isset($years[$curYear])) {
$year = new Year($curYear);
for ($i = 1; $i < 13; $i++) {
$date = clone $begin;
$date->setDate((int) $curYear, $i, (int) $begin->format('d'));
$date->setTime(0, 0, 0);
if ($date < $begin || $date > $end) {
continue;
}
$year->setMonth(new Month((string) $i));
}
$years[$curYear] = $year;
}
$tmp->modify('+1 month');
}
$qb = $this->getMonthlyStatsQuery($user, $begin, $end, null);
foreach ($qb->getQuery()->execute() as $statRow) {
if (!isset($years[$statRow['year']])) {
continue;
}
$month = $years[$statRow['year']]->getMonth((int) $statRow['month']);
if (null === $month) {
continue;
}
$month->setTotalDuration((int) $statRow['duration']);
$month->setTotalRate((float) $statRow['rate']);
}
$qb = $this->getMonthlyStatsQuery($user, $begin, $end, true);
foreach ($qb->getQuery()->execute() as $statRow) {
if (!isset($years[$statRow['year']])) {
continue;
}
$month = $years[$statRow['year']]->getMonth((int) $statRow['month']);
if (null === $month) {
continue;
}
$month->setBillableDuration((int) $statRow['duration']);
$month->setBillableRate((float) $statRow['rate']);
}
return $years;
}
/**
* @deprecated since 1.15 - use TimesheetStatisticService::getMonthlyStats() instead - will be removed with 2.0
* @codeCoverageIgnore
*
* @param User|null $user
* @param DateTime|null $begin
* @param DateTime|null $end
* @param bool|null $billable
* @return QueryBuilder
*/
private function getMonthlyStatsQuery(User $user = null, ?DateTime $begin = null, ?DateTime $end = null, ?bool $billable = null): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Timesheet::class, 't');
$qb->select('COALESCE(SUM(t.rate), 0) as rate');
$qb->addSelect('COALESCE(SUM(t.duration), 0) as duration');
$qb->addSelect('MONTH(t.date) as month');
$qb->addSelect('YEAR(t.date) as year');
if (!empty($begin)) {
$qb->andWhere($qb->expr()->gte('t.begin', ':from'));
$qb->setParameter('from', $begin);
} else {
$qb->andWhere($qb->expr()->isNotNull('t.begin'));
}
if (!empty($end)) {
$qb->andWhere($qb->expr()->lte('t.end', ':to'));
$qb->setParameter('to', $end);
} else {
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
if (null !== $user) {
$qb->andWhere('t.user = :user');
$qb->setParameter('user', $user);
}
if (null !== $billable) {
$qb->andWhere('t.billable = :billable');
$qb->setParameter('billable', $billable);
}
$qb
->orderBy('year', 'DESC')
->addOrderBy('month', 'ASC')
->groupBy('year')
->addGroupBy('month')
;
return $qb;
}
/**
* In case this method is called with one timezone and the results are from another timezone,
* it might return rows outside the time-range.
*
* @param DateTime $begin
* @param DateTime $end
* @param User|null $user
* @return mixed
*/
protected function getDailyData(DateTime $begin, DateTime $end, ?User $user = null)
{
$qb = $this->getEntityManager()->createQueryBuilder();
$or = $qb->expr()->orX();
$or->add($qb->expr()->between(':begin', 't.begin', 't.end'));
$or->add($qb->expr()->between(':end', 't.begin', 't.end'));
$or->add($qb->expr()->between('t.begin', ':begin', ':end'));
$or->add($qb->expr()->between('t.end', ':begin', ':end'));
$qb->select('t, p, a, c')
->from(Timesheet::class, 't')
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere($or)
->orderBy('t.begin', 'DESC')
->setParameter('begin', $begin)
->setParameter('end', $end)
->leftJoin('t.activity', 'a')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
;
if (null !== $user) {
$qb
->andWhere($qb->expr()->eq('t.user', ':user'))
->setParameter('user', $user)
;
}
$timesheets = $qb->getQuery()->getResult();
$results = [];
/** @var Timesheet $result */
foreach ($timesheets as $result) {
/** @var DateTime $beginTmp */
$beginTmp = $result->getBegin();
/** @var DateTime $endTmp */
$endTmp = $result->getEnd();
$dateKeyEnd = $endTmp->format('Ymd');
do {
$dateKey = $beginTmp->format('Ymd');
if ($dateKey !== $dateKeyEnd) {
$newDateBegin = clone $beginTmp;
$newDateBegin->add(new DateInterval('P1D'));
// overlapping records should always start at midnight
$newDateBegin->setTime(0, 0, 0);
} else {
$newDateBegin = clone $endTmp;
}
// make sure to exclude entries that are outside the requested time-range:
// these entries can exist if you have long running entries that started before $begin
// for statistical reasons we have to include everything between $begin and $end while
// excluding everything that is outside of that range
// --------------------------------------------------------------------------------------
// Be aware that this will NOT filter every record, in case there is a timezone mismatch between the
// begin/end dates and the ones from the database (eg. recorded in UTC) - which might actually be
// before $begin (which happens thanks to the timezone conversion when querying the database)
if ($newDateBegin > $begin && $beginTmp < $end) {
if (!isset($results[$dateKey])) {
$results[$dateKey] = [
'rate' => 0,
'duration' => 0,
'billable' => 0, // duration
'month' => $beginTmp->format('n'),
'year' => $beginTmp->format('Y'),
'day' => $beginTmp->format('j'),
'details' => []
];
}
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
$durationPercent = 0;
if ($result->getDuration() !== null && $result->getDuration() > 0) {
$durationPercent = $duration / $result->getDuration();
}
$rate = $result->getRate() * $durationPercent;
$results[$dateKey]['rate'] += $rate;
$results[$dateKey]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['billable'] += $duration;
}
$detailsId =
$result->getProject()->getCustomer()->getId()
. '_' . $result->getProject()->getId()
. '_' . $result->getActivity()->getId()
;
if (!isset($results[$dateKey]['details'][$detailsId])) {
$results[$dateKey]['details'][$detailsId] = [
'project' => $result->getProject(),
'activity' => $result->getActivity(),
'duration' => 0,
'rate' => 0,
'billable' => 0, // duration
];
}
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
if ($result->isBillable()) {
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
}
}
$beginTmp = $newDateBegin;
// yes, we only want to compare the day, not the time
if ((int) $end->format('Ymd') < (int) $newDateBegin->format('Ymd')) {
break;
}
} while ($dateKey !== $dateKeyEnd);
}
ksort($results);
foreach ($results as $key => $value) {
$results[$key]['details'] = array_values($results[$key]['details']);
}
$results = array_values($results);
return $results;
}
/**
* @deprecated since 1.15 - use TimesheetStatisticService::getDailyStatistics() instead
* @codeCoverageIgnore
*
* @param User|null $user
* @param DateTime $begin
* @param DateTime $end
* @return Day[]
* @throws Exception
*/
public function getDailyStats(?User $user, DateTime $begin, DateTime $end): array
{
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = clone $end;
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') >= $until) {
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
$tmp->modify('-1 day');
}
$results = $this->getDailyData($begin, $end, $user);
foreach ($results as $statRow) {
$dateTime = clone $begin;
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
$dateTime->setTime(0, 0, 0);
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
$day->setTotalDurationBillable($statRow['billable']);
$day->setDetails($statRow['details']);
$dateKey = $dateTime->format('Ymd');
// make sure entries from other timezones are filtered
if (!\array_key_exists($dateKey, $days)) {
continue;
}
$days[$dateKey] = $day;
}
ksort($days);
return array_values($days);
}
/**
* @param User $user
* @return Timesheet[]|null
*/
public function getActiveEntries(User $user = null)
public function getActiveEntries(User $user = null, bool $ticktac = false): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('t')
->from(Timesheet::class, 't')
->andWhere($qb->expr()->isNotNull('t.begin'))
->andWhere($qb->expr()->isNull('t.end'))
->orderBy('t.begin', 'DESC');
@@ -720,47 +360,13 @@ class TimesheetRepository extends EntityRepository
$qb->setParameter('user', $user);
}
return $this->getHydratedResultsByQuery($qb, false);
}
if ($ticktac) {
$qb->setMaxResults(1);
/**
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
* @codeCoverageIgnore
*
* @param User $user
* @param int $hardLimit
* @param bool $flush
* @return int
* @throws RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function stopActiveEntries(User $user, int $hardLimit, bool $flush = true)
{
$counter = 0;
$activeEntries = $this->getActiveEntries($user);
// reduce limit by one:
// this method is only called when a new entry is started
// -> all entries, including the new one must not exceed the $limit
$limit = $hardLimit - 1;
if (\count($activeEntries) > $limit) {
$i = 1;
foreach ($activeEntries as $activeEntry) {
if ($i > $limit) {
if ($hardLimit > 1) {
throw new Exception('timesheet.start.exceeded_limit');
}
$this->stopRecording($activeEntry, $flush);
$counter++;
}
$i++;
}
return $qb->getQuery()->getResult();
}
return $counter;
return $this->getHydratedResultsByQuery($qb, false);
}
/**
@@ -818,9 +424,9 @@ class TimesheetRepository extends EntityRepository
return true;
}
public function getPagerfantaForQuery(TimesheetQuery $query): Pagerfanta
public function getPagerfantaForQuery(TimesheetQuery $query): Pagination
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator = new Pagination($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
@@ -844,36 +450,38 @@ class TimesheetRepository extends EntityRepository
/**
* 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!
* 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): iterable
public function getTimesheetsForQuery(TimesheetQuery $query, bool $fullyHydrated = false, bool $basicHydrated = true): iterable
{
$qb = $this->getQueryBuilderForQuery($query);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated, $basicHydrated);
}
public function getTimesheetResult(TimesheetQuery $query): TimesheetResult
{
$qb = $this->getQueryBuilderForQuery($query);
return new TimesheetResult($qb);
return new TimesheetResult($query, $qb);
}
/**
* @param QueryBuilder $qb
* @param bool $fullyHydrated
* @param bool $basicHydrated
* @return Timesheet[]
*/
protected function getHydratedResultsByQuery(QueryBuilder $qb, bool $fullyHydrated = false): iterable
private function getHydratedResultsByQuery(QueryBuilder $qb, bool $fullyHydrated = false, bool $basicHydrated = true): iterable
{
$results = $qb->getQuery()->getResult();
$loader = new TimesheetLoader($qb->getEntityManager(), $fullyHydrated);
$loader = new TimesheetLoader($qb->getEntityManager(), $fullyHydrated, $basicHydrated);
$loader->loadResults($results);
return $results;
@@ -920,7 +528,7 @@ class TimesheetRepository extends EntityRepository
$user = array_merge($user, $query->getUsers());
if (empty($user) && null !== ($currentUser = $query->getCurrentUser()) && !$currentUser->canSeeAllData()) {
if (\count($user) === 0 && null !== ($currentUser = $query->getCurrentUser()) && !$currentUser->canSeeAllData()) {
// make sure that the user himself is in the list of users, if he is part of a team
// if teams are used and the user is not a teamlead, the list of users would be empty and then leading to NOT limit the select by user IDs
$user[] = $currentUser;
@@ -932,25 +540,18 @@ class TimesheetRepository extends EntityRepository
}
}
if (!empty($query->getTeams())) {
foreach ($query->getTeams() as $team) {
foreach ($team->getUsers() as $teamUser) {
$user[] = $teamUser;
}
foreach ($query->getTeams() as $team) {
foreach ($team->getUsers() as $teamUser) {
$user[] = $teamUser;
}
}
$user = array_map(function ($user) {
if ($user instanceof User) {
return $user->getId();
}
$userIds = array_unique(array_map(function (User $user) {
return $user->getId();
}, $user));
return $user;
}, $user);
$user = array_unique($user);
if (!empty($user)) {
$qb->andWhere($qb->expr()->in('t.user', $user));
if (\count($userIds) > 0) {
$qb->andWhere($qb->expr()->in('t.user', $userIds));
}
if (null !== $query->getBegin()) {
@@ -988,22 +589,22 @@ class TimesheetRepository extends EntityRepository
if ($query->hasActivities()) {
$qb->andWhere($qb->expr()->in('t.activity', ':activity'))
->setParameter('activity', $query->getActivities());
->setParameter('activity', $query->getActivityIds());
}
if ($query->hasProjects()) {
$qb->andWhere($qb->expr()->in('t.project', ':project'))
->setParameter('project', $query->getProjects());
->setParameter('project', $query->getProjectIds());
} elseif ($query->hasCustomers()) {
$requiresCustomer = true;
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
->setParameter('customer', $query->getCustomers());
->setParameter('customer', $query->getCustomerIds());
}
$tags = $query->getTags();
if (!empty($tags)) {
if (\count($tags) > 0) {
$qb->andWhere($qb->expr()->isMemberOf(':tags', 't.tags'))
->setParameter('tags', $query->getTags());
->setParameter('tags', $tags);
}
$requiresTeams = $this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
@@ -1048,37 +649,52 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param User|null $user
* @param User $user
* @param DateTime|null $startFrom
* @param int $limit
* @return Timesheet[]
* @throws \Doctrine\ORM\Query\QueryException
*/
public function getRecentActivities(User $user = null, DateTime $startFrom = null, int $limit = 10)
public function getRecentActivities(User $user, DateTime $startFrom = null, int $limit = 10): array
{
return $this->findTimesheetsById(
$this->getRecentActivityIds($user, $startFrom, $limit)
);
}
/**
* @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
{
$qb = $this->getEntityManager()->createQueryBuilder();
// do NOT join the customer and do NOT check the customer visibility, as this
// will dramatically increase the speed of this (otherwise slow) query
// ->join('p.customer', 'c')
// ->andWhere($qb->expr()->eq('c.visible', ':visible'))
// you might want to join activity and project to check their visibility
// but for now this is way slower than simply fetching more items
//
// ->join('t.project', 'p')
// ->andWhere($qb->expr()->eq('p.visible', ':visible'))
// ->join('t.activity', 'a')
// ->andWhere($qb->expr()->eq('a.visible', ':visible'))
// ->setParameter('visible', true, Types::BOOLEAN)
$qb->select($qb->expr()->max('t.id') . ' AS maxid')
->from(Timesheet::class, 't')
->indexBy('t', 't.id')
->join('t.activity', 'a')
->join('t.project', 'p')
->join('p.customer', 'c')
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->eq('a.visible', ':visible'))
->andWhere($qb->expr()->eq('p.visible', ':visible'))
->andWhere($qb->expr()->eq('c.visible', ':visible'))
->groupBy('a.id', 'p.id')
->andWhere($qb->expr()->eq('t.user', ':user'))
->groupBy('t.project', 't.activity')
->orderBy('maxid', 'DESC')
->setMaxResults($limit)
->setParameter('visible', true, Types::BOOLEAN)
->setParameter('user', $user)
;
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
if (null !== $startFrom) {
$qb->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $startFrom);
@@ -1090,7 +706,18 @@ class TimesheetRepository extends EntityRepository
return [];
}
$ids = array_column($results, 'maxid');
return array_column($results, 'maxid');
}
/**
* @param array<int> $ids
* @return array<Timesheet>
*/
public function findTimesheetsById(array $ids, bool $fullyHydrated = false, bool $basicHydrated = true): array
{
if (\count($ids) === 0) {
return [];
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('t')
@@ -1099,7 +726,7 @@ class TimesheetRepository extends EntityRepository
->orderBy('t.end', 'DESC')
;
return $this->getHydratedResultsByQuery($qb, true);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated, $basicHydrated);
}
/**

View File

@@ -10,32 +10,40 @@
namespace App\Repository;
use App\Entity\Invoice;
use App\Entity\Role;
use App\Entity\Team;
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\PaginatorInterface;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* @extends \Doctrine\ORM\EntityRepository<User>
*/
class UserRepository extends EntityRepository implements UserLoaderInterface
class UserRepository extends EntityRepository implements UserLoaderInterface, UserProviderInterface, PasswordUpgraderInterface
{
public function getById($id): ?User
public function deleteUserPreference(UserPreference $preference, bool $flush = false): void
{
@trigger_error('UserRepository::getById is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
return $this->getUserById($id);
$entityManager = $this->getEntityManager();
$entityManager->remove($preference);
if ($flush) {
$entityManager->flush();
}
}
/**
@@ -50,6 +58,20 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
$entityManager->flush();
}
public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void
{
if (!($user instanceof User)) {
return;
}
try {
$user->setPassword($newHashedPassword);
$this->saveUser($user);
} catch (\Exception $ex) {
// happens during login: if it fails, ignore it!
}
}
/**
* Used to fetch a user by its ID.
*
@@ -93,10 +115,10 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
* Overwritten to fetch preferences when using the Profile controller actions.
* Depends on the query, some magic mechanisms like the ParamConverter will use this method to fetch the user.
*/
public function findOneBy(array $criteria, array $orderBy = null)
public function findOneBy(array $criteria, array $orderBy = null): ?object
{
if (\count($criteria) == 1 && isset($criteria['username'])) {
return $this->loadUserByUsername($criteria['username']);
if (\count($criteria) === 1 && isset($criteria['username'])) {
return $this->loadUserByIdentifier($criteria['username']);
}
return parent::findOneBy($criteria, $orderBy);
@@ -110,59 +132,48 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
public function countUser(?bool $enabled = null): int
{
if (null !== $enabled) {
return $this->count(['enabled' => (bool) $enabled]);
return $this->count(['enabled' => $enabled]);
}
return $this->count([]);
}
/**
* @param UserQuery $query
* @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
* @deprecated since 1.4, use getUsersForQuery() instead
* @param string $identifier
* @return User
* @throws UserNotFoundException
*/
public function findByQuery(UserQuery $query)
{
@trigger_error('UserRepository::findByQuery() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
if (BaseQuery::RESULT_TYPE_PAGER === $query->getResultType()) {
return $this->getPagerfantaForQuery($query);
}
$qb = $this->getQueryBuilderForQuery($query);
if (BaseQuery::RESULT_TYPE_OBJECTS === $query->getResultType()) {
return $qb->getQuery()->execute();
}
return $qb;
}
/**
* @param string $username
* @return null|User
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function loadUserByUsername($username)
public function loadUserByIdentifier(string $identifier): UserInterface
{
/** @var User|null $user */
$user = $this->createQueryBuilder('u')
->select('u')
->where('u.username = :username')
->orWhere('u.email = :username')
->setParameter('username', $username)
->setParameter('username', $identifier)
->getQuery()
->getOneOrNullResult();
if ($user !== null) {
$loader = new UserLoader($this->getEntityManager(), true);
$loader->loadResults([$user]);
if ($user === null) {
throw new UserNotFoundException();
}
$loader = new UserLoader($this->getEntityManager(), true);
$loader->loadResults([$user]);
return $user;
}
public function refreshUser(UserInterface $user): User
{
return $this->loadUserByIdentifier($user->getUserIdentifier());
}
public function supportsClass(string $class): bool
{
return $class === User::class;
}
public function getQueryBuilderForFormType(UserFormTypeQuery $query): QueryBuilder
{
$qb = $this->createQueryBuilder('u');
@@ -171,7 +182,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
if ($query->isShowVisible()) {
$or->add($qb->expr()->eq('u.enabled', ':enabled'));
$qb->setParameter('enabled', true, \PDO::PARAM_BOOL);
$qb->setParameter('enabled', true, ParameterType::BOOLEAN);
}
$includeAlways = $query->getUsersAlwaysIncluded();
@@ -192,6 +203,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
$qb->andWhere($qb->expr()->notIn('u.id', $ids));
}
$qb->andWhere($qb->expr()->eq('u.systemAccount', ':system'));
$qb->setParameter('system', false, Types::BOOLEAN);
$qb->orderBy('u.username', 'ASC');
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
@@ -238,8 +252,8 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
if (\count($teams) > 0) {
$userIds = [];
foreach ($teams as $team) {
foreach ($team->getUsers() as $user) {
$userIds[] = $user->getId();
foreach ($team->getUsers() as $teamMember) {
$userIds[] = $teamMember->getId();
}
}
$userIds = array_unique($userIds);
@@ -287,9 +301,21 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
$qb
->select('u')
->from(User::class, 'u')
->orderBy('u.' . $query->getOrderBy(), $query->getOrder())
;
foreach ($query->getOrderGroups() as $orderBy => $order) {
switch ($orderBy) {
case 'user':
$qb->addSelect('COALESCE(u.alias, u.username) as HIDDEN userOrder');
$orderBy = 'userOrder';
break;
default:
$orderBy = 'u.' . $orderBy;
break;
}
$qb->addOrderBy($orderBy, $order);
}
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
if (\count($query->getSearchTeams()) > 0) {
@@ -305,10 +331,10 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
if ($query->isShowVisible()) {
$qb->andWhere($qb->expr()->eq('u.enabled', ':enabled'));
$qb->setParameter('enabled', true, \PDO::PARAM_BOOL);
$qb->setParameter('enabled', true, ParameterType::BOOLEAN);
} elseif ($query->isShowHidden()) {
$qb->andWhere($qb->expr()->eq('u.enabled', ':enabled'));
$qb->setParameter('enabled', false, \PDO::PARAM_BOOL);
$qb->setParameter('enabled', false, ParameterType::BOOLEAN);
}
if ($query->getRole() !== null) {
@@ -322,6 +348,11 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
$qb->andWhere($rolesWhere);
}
if ($query->getSystemAccount() !== null) {
$qb->andWhere($qb->expr()->eq('u.systemAccount', ':system'));
$qb->setParameter('system', $query->getSystemAccount(), Types::BOOLEAN);
}
if ($query->hasSearchTerm()) {
$searchAnd = $qb->expr()->andX();
$searchTerm = $query->getSearchTerm();
@@ -359,9 +390,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
return $qb;
}
public function getPagerfantaForQuery(UserQuery $query): Pagerfanta
public function getPagerfantaForQuery(UserQuery $query): Pagination
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
$paginator = new Pagination($this->getPaginatorForQuery($query));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
@@ -425,8 +456,8 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
->update(Timesheet::class, 't')
->set('t.user', ':replace')
->where('t.user = :delete')
->setParameter('delete', $delete)
->setParameter('replace', $replace)
->setParameter('delete', $delete->getId())
->setParameter('replace', $replace->getId())
->getQuery()
->execute();
@@ -435,8 +466,8 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
->update(Invoice::class, 'i')
->set('i.user', ':replace')
->where('i.user = :delete')
->setParameter('delete', $delete)
->setParameter('replace', $replace)
->setParameter('delete', $delete->getId())
->setParameter('replace', $replace->getId())
->getQuery()
->execute();
}

View File

@@ -1,270 +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;
use App\Widget\Type\Counter;
use App\Widget\Type\SimpleStatisticChart;
use App\Widget\WidgetException;
use App\Widget\WidgetInterface;
/**
* @internal
*/
class WidgetRepository
{
/**
* @var TimesheetRepository
*/
private $repository;
/**
* @var array
*/
private $widgets = [];
/**
* @var array
*/
private $definitions;
/**
* @var array
*/
private $customDefinition;
public function __construct(TimesheetRepository $repository, array $widgets)
{
$this->repository = $repository;
$this->customDefinition = $widgets;
}
private function getDefinedWidgets(): array
{
if (null === $this->definitions) {
$this->definitions = array_merge($this->getDefaultWidgets(), $this->customDefinition);
}
return $this->definitions;
}
public function has(string $id): bool
{
return isset($this->getDefinedWidgets()[$id]) || isset($this->widgets[$id]);
}
public function registerWidget(WidgetInterface $widget): WidgetRepository
{
if (!empty($widget->getId())) {
$this->widgets[$widget->getId()] = $widget;
}
return $this;
}
public function get(string $id): WidgetInterface
{
if (!$this->has($id)) {
throw new \InvalidArgumentException(sprintf('Cannot find widget "%s".', $id));
}
if (isset($this->widgets[$id])) {
return $this->widgets[$id];
}
// this code should ONLY be reached for internal (pre-registered) widgets
$this->registerWidget($this->create($id, $this->getDefinedWidgets()[$id]));
return $this->widgets[$id];
}
/**
* @param string $name
* @param array $widget
* @return WidgetInterface
* @throws WidgetException
*/
protected function create(string $name, array $widget): WidgetInterface
{
if (!isset($widget['type'])) {
@trigger_error('Using a widget definition without a "type" is deprecated', E_USER_DEPRECATED);
$widget['type'] = Counter::class;
}
$widgetClassName = ucfirst($widget['type']);
if (!class_exists($widgetClassName)) {
throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetClassName));
}
/** @var SimpleStatisticChart $model */
$model = new $widgetClassName($this->repository);
if (!($model instanceof SimpleStatisticChart)) {
throw new WidgetException(
sprintf(
'Widget type "%s" is not an instance of "%s"',
$widgetClassName,
SimpleStatisticChart::class
)
);
}
$model
->setQuery($widget['query'])
->setBegin($widget['begin'])
->setEnd($widget['end'])
->setId($name)
->setTitle($widget['title'])
;
if ($widget['query'] === TimesheetRepository::STATS_QUERY_DURATION) {
$model->setOption('dataType', 'duration');
} elseif ($widget['query'] === TimesheetRepository::STATS_QUERY_RATE) {
$model->setOption('dataType', 'money');
} else {
$model->setOption('dataType', 'int');
}
if (isset($widget['user'])) {
$model->setQueryWithUser((bool) $widget['user']);
}
if (isset($widget['color'])) {
$model->setOption('color', $widget['color']);
}
if (isset($widget['icon'])) {
$model->setOption('icon', $widget['icon']);
}
return $model;
}
protected function getDefaultWidgets(): array
{
return
[
'userDurationToday' => [
'title' => 'stats.durationToday',
'query' => TimesheetRepository::STATS_QUERY_DURATION,
'user' => true,
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'duration',
'color' => 'green',
'type' => Counter::class,
],
'userDurationWeek' => [
'title' => 'stats.durationWeek',
'query' => TimesheetRepository::STATS_QUERY_DURATION,
'user' => true,
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'duration',
'color' => 'blue',
'type' => Counter::class,
],
'userDurationMonth' => [
'title' => 'stats.durationMonth',
'query' => TimesheetRepository::STATS_QUERY_DURATION,
'user' => true,
'begin' => 'first day of this month 00:00:00',
'end' => 'last day of this month 23:59:59',
'icon' => 'duration',
'color' => 'purple',
'type' => Counter::class,
],
'userDurationTotal' => [
'title' => 'stats.durationTotal',
'query' => TimesheetRepository::STATS_QUERY_DURATION,
'user' => true,
'icon' => 'duration',
'color' => 'red',
'type' => Counter::class,
],
'durationToday' => [
'title' => 'stats.durationToday',
'query' => TimesheetRepository::STATS_QUERY_DURATION,
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'duration',
'color' => 'green',
'user' => false,
'type' => Counter::class,
],
'durationWeek' => [
'title' => 'stats.durationWeek',
'query' => TimesheetRepository::STATS_QUERY_DURATION,
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'duration',
'color' => 'blue',
'user' => false,
'type' => Counter::class,
],
'durationMonth' => [
'title' => 'stats.durationMonth',
'query' => TimesheetRepository::STATS_QUERY_DURATION,
'begin' => 'first day of this month 00:00:00',
'end' => 'last day of this month 23:59:59',
'icon' => 'duration',
'color' => 'purple',
'user' => false,
'type' => Counter::class,
],
'durationTotal' => [
'title' => 'stats.durationTotal',
'query' => TimesheetRepository::STATS_QUERY_DURATION,
'icon' => 'duration',
'color' => 'red',
'user' => false,
'type' => Counter::class,
],
'activeUsersToday' => [
'title' => 'stats.userActiveToday',
'query' => TimesheetRepository::STATS_QUERY_USER,
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'user',
'color' => 'green',
'user' => false,
'type' => Counter::class,
],
'activeUsersWeek' => [
'title' => 'stats.userActiveWeek',
'query' => TimesheetRepository::STATS_QUERY_USER,
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'user',
'color' => 'blue',
'user' => false,
'type' => Counter::class,
],
'activeUsersMonth' => [
'title' => 'stats.userActiveMonth',
'query' => TimesheetRepository::STATS_QUERY_USER,
'begin' => 'first day of this month 00:00:00',
'end' => 'last day of this month 23:59:59',
'icon' => 'user',
'color' => 'purple',
'user' => false,
'type' => Counter::class,
],
'activeUsersTotal' => [
'title' => 'stats.userActiveTotal',
'query' => TimesheetRepository::STATS_QUERY_USER,
'icon' => 'user',
'color' => 'red',
'user' => false,
'type' => Counter::class,
],
'activeRecordings' => [
'title' => 'stats.activeRecordings',
'query' => TimesheetRepository::STATS_QUERY_ACTIVE,
'icon' => 'duration',
'color' => 'red',
'user' => false,
'type' => Counter::class,
],
];
}
}