Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
GridQuerySpecification
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
11 / 11
32
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 parsePagination
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 parseSorting
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
10
 parseFilters
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
8
 getPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOffset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSortField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSortDirection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedColumns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Shared\Query;
6
7 class GridQuerySpecification
8{
9    public const DEFAULT_PAGE_SIZE = 10;
10    public const ALLOWED_PAGE_SIZES = [10, 25, 50, 100];
11
12    private int $page;
13    private int $pageSize;
14    private ?string $sortField = null;
15    private string $sortDirection = 'ASC';
16
17    /**
18     * Filter inputs per column: ['column_name' => 'search_term']
19     *
20     * @var array<string, string>
21     */
22    private array $filters = [];
23
24    /**
25     * List of allowed column names for sorting and filtering (security whitelist)
26     *
27     * @var array<string, string>
28     */
29    private array $allowedColumns = [];
30
31    /**
32     * @param array<string, mixed> $queryParams Usually $request->getQueryParams()
33     * @param array<string, string> $allowedColumns Map of allowed column key => SQL column expression (e.g. ['name' => 'h.name'])
34     * @param string|null $defaultSortField Default sort column if not specified in query params
35     * @param string $defaultSortDirection Default sort direction ('ASC' or 'DESC')
36     */
37    public function __construct(
38        array $queryParams,
39        array $allowedColumns,
40        ?string $defaultSortField = null,
41        string $defaultSortDirection = 'DESC'
42    ) {
43        $this->allowedColumns = $allowedColumns;
44
45        $this->parsePagination($queryParams);
46        $this->parseSorting($queryParams, $defaultSortField, $defaultSortDirection);
47        $this->parseFilters($queryParams);
48    }
49
50    /**
51     * @param array<string, mixed> $queryParams
52     */
53    private function parsePagination(array $queryParams): void
54    {
55        $rawPage = isset($queryParams['page']) && is_numeric($queryParams['page']) ? (int) $queryParams['page'] : 1;
56        $this->page = max(1, $rawPage);
57
58        $rawPageSize = isset($queryParams['pageSize']) && is_numeric($queryParams['pageSize']) ? (int) $queryParams['pageSize'] : self::DEFAULT_PAGE_SIZE;
59        $this->pageSize = in_array($rawPageSize, self::ALLOWED_PAGE_SIZES, true) ? $rawPageSize : self::DEFAULT_PAGE_SIZE;
60    }
61
62    /**
63     * @param array<string, mixed> $queryParams
64     */
65    private function parseSorting(array $queryParams, ?string $defaultSortField, string $defaultSortDirection): void
66    {
67        if (isset($queryParams['sort']) && is_string($queryParams['sort']) && $queryParams['sort'] !== '') {
68            $rawSort = trim($queryParams['sort']);
69            if (str_starts_with($rawSort, '-')) {
70                $this->sortDirection = 'DESC';
71                $field = substr($rawSort, 1);
72            } else {
73                $this->sortDirection = 'ASC';
74                $field = ltrim($rawSort, '+');
75            }
76
77            if (array_key_exists($field, $this->allowedColumns)) {
78                $this->sortField = $field;
79            }
80        }
81
82        if ($this->sortField === null && $defaultSortField !== null && array_key_exists($defaultSortField, $this->allowedColumns)) {
83            $this->sortField = $defaultSortField;
84            $this->sortDirection = strtoupper($defaultSortDirection) === 'ASC' ? 'ASC' : 'DESC';
85        }
86    }
87
88    /**
89     * @param array<string, mixed> $queryParams
90     */
91    private function parseFilters(array $queryParams): void
92    {
93        if (isset($queryParams['filter']) && is_array($queryParams['filter'])) {
94            foreach ($queryParams['filter'] as $col => $val) {
95                if (is_string($col) && is_string($val) && trim($val) !== '' && array_key_exists($col, $this->allowedColumns)) {
96                    $this->filters[$col] = trim($val);
97                }
98            }
99        }
100    }
101
102    public function getPage(): int
103    {
104        return $this->page;
105    }
106
107    public function getPageSize(): int
108    {
109        return $this->pageSize;
110    }
111
112    public function getOffset(): int
113    {
114        return ($this->page - 1) * $this->pageSize;
115    }
116
117    public function getSortField(): ?string
118    {
119        return $this->sortField;
120    }
121
122    public function getSortDirection(): string
123    {
124        return $this->sortDirection;
125    }
126
127    /**
128     * @return array<string, string>
129     */
130    public function getFilters(): array
131    {
132        return $this->filters;
133    }
134
135    /**
136     * @return array<string, string>
137     */
138    public function getAllowedColumns(): array
139    {
140        return $this->allowedColumns;
141    }
142}