global pagination support for API collection calls

This commit is contained in:
Kevin Papst
2024-03-10 13:27:04 +01:00
parent e293d62aa1
commit 3e3cdc5e69
9 changed files with 107 additions and 102 deletions

View File

@@ -8,6 +8,18 @@ you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [2.14.0](https://github.com/kimai/kimai/releases/tag/2.13.0)
**DEVELOPERS ONLY**
The API now uses pagination globally for all collection endpoints. This might break current implementations
All collections are limited to 50 entries by default (changes in Customer, Project, Activity, User).
The parameters to influence pagination, page size and page number are `size` (and `pageSize`) and `page`.
Read the [API pagination docs](https://www.kimai.org/documentation/api-pagination.html) for more infos.
## [2.0.30](https://github.com/kimai/kimai/releases/tag/2.0.30)
The `DATABASE_URL` in your environment settings ([.env](https://github.com/kimai/kimai/issues/4246) or in your [Docker](https://github.com/tobybatch/kimai2/issues/531) or webserver config)

View File

@@ -44,10 +44,10 @@ final class ActivityController extends BaseApiController
public const GROUPS_RATE = ['Default', 'Entity', 'Activity_Rate'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private ActivityRepository $repository,
private EventDispatcherInterface $dispatcher,
private ActivityRateRepository $activityRateRepository
private readonly ViewHandlerInterface $viewHandler,
private readonly ActivityRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityRateRepository $activityRateRepository
) {
}
@@ -64,14 +64,13 @@ final class ActivityController extends BaseApiController
#[Rest\QueryParam(name: 'globals', strict: true, nullable: true, description: 'Use if you want to fetch only global activities. Allowed values: true (default: false)')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|project', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, project (default: name)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher, ProjectRepository $projectRepository): Response
{
/** @var User $user */
$user = $this->getUser();
$query = new ActivityQuery();
$query->setCurrentUser($user);
$this->prepareBaseQuery($query, $paramFetcher);
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
@@ -112,9 +111,9 @@ final class ActivityController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getActivitiesForQuery($query);
$view = new View($data, 200);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createViewForPagination($pagination);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Repository\Query\BaseQuery;
use App\Timesheet\DateTimeFactory;
use App\Utils\Pagination;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
@@ -52,11 +53,45 @@ abstract class BaseApiController extends AbstractController
return DateTimeFactory::createByUser($user);
}
protected function addPagination(View $view, Pagination $pagination): void
protected function prepareBaseQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): void
{
$query->setIsApiCall(true);
$query->setCurrentUser($this->getUser());
$all = $paramFetcher->all(true);
if (array_key_exists('page', $all)) {
$page = $all['page'];
if (\is_string($page) && $page !== '') {
$query->setPage((int) $page);
}
}
if (array_key_exists('size', $all)) {
$size = $all['size'];
if (\is_string($size) && $size !== '') {
$query->setPageSize((int)$size);
}
}
if (array_key_exists('pageSize', $all)) {
$size = $all['pageSize'];
if (\is_string($size) && $size !== '') {
$query->setPageSize((int)$size);
}
}
}
protected function createViewForPagination(Pagination $pagination): View
{
$results = (array) $pagination->getCurrentPageResults();
$view = new View($results, 200);
$view->setHeader('X-Page', (string) $pagination->getCurrentPage());
$view->setHeader('X-Total-Count', (string) $pagination->getNbResults());
$view->setHeader('X-Total-Pages', (string) $pagination->getNbPages());
$view->setHeader('X-Per-Page', (string) $pagination->getMaxPerPage());
return $view;
}
}

View File

@@ -44,10 +44,10 @@ final class CustomerController extends BaseApiController
public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private CustomerRepository $repository,
private EventDispatcherInterface $dispatcher,
private CustomerRateRepository $customerRateRepository
private readonly ViewHandlerInterface $viewHandler,
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly CustomerRateRepository $customerRateRepository
) {
}
@@ -61,14 +61,13 @@ final class CustomerController extends BaseApiController
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter customers: 1=visible, 2=hidden, 3=both')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name (default: name)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
/** @var User $user */
$user = $this->getUser();
$query = new CustomerQuery();
$query->setCurrentUser($user);
$this->prepareBaseQuery($query, $paramFetcher);
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
@@ -90,9 +89,9 @@ final class CustomerController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getCustomersForQuery($query);
$view = new View($data, 200);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createViewForPagination($pagination);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);

View File

@@ -46,11 +46,11 @@ final class ProjectController extends BaseApiController
public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private ProjectRepository $repository,
private EventDispatcherInterface $dispatcher,
private ProjectRateRepository $projectRateRepository,
private ProjectService $projectService
private readonly ViewHandlerInterface $viewHandler,
private readonly ProjectRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly ProjectRateRepository $projectRateRepository,
private readonly ProjectService $projectService
) {
}
@@ -70,14 +70,13 @@ final class ProjectController extends BaseApiController
#[Rest\QueryParam(name: 'globalActivities', requirements: '0|1', strict: true, nullable: true, description: "If given, filters projects by their 'global activity' support. Allowed values: 1 (supports global activities) and 0 (without global activities) (default: all)")]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|customer', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, customer (default: name)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository): Response
{
/** @var User $user */
$user = $this->getUser();
$query = new ProjectQuery();
$query->setCurrentUser($user);
$this->prepareBaseQuery($query, $paramFetcher);
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
@@ -144,9 +143,9 @@ final class ProjectController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getProjectsForQuery($query);
$view = new View($data, 200);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createViewForPagination($pagination);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);

View File

@@ -54,11 +54,11 @@ final class TimesheetController extends BaseApiController
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'Timesheet', 'Expanded'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private TimesheetRepository $repository,
private TagRepository $tagRepository,
private EventDispatcherInterface $dispatcher,
private TimesheetService $service
private readonly ViewHandlerInterface $viewHandler,
private readonly TimesheetRepository $repository,
private readonly TagRepository $tagRepository,
private readonly EventDispatcherInterface $dispatcher,
private readonly TimesheetService $service
) {
}
@@ -94,11 +94,13 @@ final class TimesheetController extends BaseApiController
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
#[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.')]
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{
$query = new TimesheetQuery(false);
$this->prepareBaseQuery($query, $paramFetcher);
$seeAll = false;
if ($this->isGranted('view_other_timesheet')) {
@@ -170,16 +172,6 @@ final class TimesheetController extends BaseApiController
$query->addActivity($activity);
}
$page = $paramFetcher->get('page');
if (\is_string($page) && $page !== '') {
$query->setPage((int) $page);
}
$size = $paramFetcher->get('size');
if (\is_string($size) && $size !== '') {
$query->setPageSize((int) $size);
}
/** @var array<string> $tags */
$tags = $paramFetcher->get('tags');
if (\is_array($tags) && \count($tags) > 0) {
@@ -250,12 +242,8 @@ final class TimesheetController extends BaseApiController
$query->setModifiedAfter($factory->createDateTime($modifiedAfter));
}
$query->setIsApiCall(true);
$data = $this->repository->getPagerfantaForQuery($query);
$results = (array) $data->getCurrentPageResults();
$view = new View($results, 200);
$this->addPagination($view, $data);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createViewForPagination($pagination);
$full = $paramFetcher->get('full');
if ($full === '1' || $full === 'true') {

View File

@@ -42,10 +42,10 @@ final class UserController extends BaseApiController
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'User', 'User_Entity'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private UserRepository $repository,
private UserPasswordHasherInterface $passwordHasher,
private SystemConfiguration $configuration
private readonly ViewHandlerInterface $viewHandler,
private readonly UserRepository $repository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly SystemConfiguration $configuration
) {
}
@@ -60,12 +60,14 @@ final class UserController extends BaseApiController
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$query = new UserQuery();
$query->setCurrentUser($this->getUser());
$this->prepareBaseQuery($query, $paramFetcher);
$visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') {
@@ -87,9 +89,8 @@ final class UserController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getUsersForQuery($query);
$view = new View($data, 200);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createViewForPagination($pagination);
$full = $paramFetcher->get('full');
if ($full === '1' || $full === 'true') {

View File

@@ -58,31 +58,13 @@ class ProjectRepository extends EntityRepository
return $projects;
}
/**
* @param Project $project
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveProject(Project $project)
public function saveProject(Project $project): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($project);
$entityManager->flush();
}
/**
* @param null|bool $visible
* @return int
*/
public function countProject($visible = null): int
{
if (null !== $visible) {
return $this->count(['visible' => (bool) $visible]);
}
return $this->count([]);
}
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
@@ -139,9 +121,6 @@ class ProjectRepository extends EntityRepository
/**
* Returns a query builder that is used for ProjectType and your own 'query_builder' option.
*
* @param ProjectFormTypeQuery $query
* @return QueryBuilder
*/
public function getQueryBuilderForFormType(ProjectFormTypeQuery $query): QueryBuilder
{
@@ -359,7 +338,6 @@ class ProjectRepository extends EntityRepository
}
/**
* @param ProjectQuery $query
* @return Project[]
*/
public function getProjectsForQuery(ProjectQuery $query): iterable
@@ -372,12 +350,7 @@ class ProjectRepository extends EntityRepository
return $results;
}
/**
* @param Project $delete
* @param Project|null $replace
* @throws \Doctrine\ORM\Exception\ORMException
*/
public function deleteProject(Project $delete, ?Project $replace = null)
public function deleteProject(Project $delete, ?Project $replace = null): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -429,14 +402,14 @@ class ProjectRepository extends EntityRepository
return $qb->getQuery()->getResult();
}
public function saveComment(ProjectComment $comment)
public function saveComment(ProjectComment $comment): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($comment);
$entityManager->flush();
}
public function deleteComment(ProjectComment $comment)
public function deleteComment(ProjectComment $comment): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($comment);

View File

@@ -41,9 +41,8 @@ class PaginationTest extends TestCase
$query = new TimesheetQuery();
$query->setPage(3);
$query->setPageSize(1);
$query->setIsApiCall(true);
$sut = new Pagination(new ArrayAdapter([1, 2, 3, 4, 5]), $query);
$this->assertFalse($sut->getNormalizeOutOfRangePages());
$this->assertTrue($sut->getNormalizeOutOfRangePages());
$this->assertEquals(1, $sut->getMaxPerPage());
$this->assertEquals(3, $sut->getCurrentPage());
}