Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
GenericDataGrid
100.00% covered (success)
100.00%
103 / 103
100.00% covered (success)
100.00%
10 / 10
38
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getColumns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 renderWithResult
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fetchData
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
8
 resolveSort
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 buildWhereClause
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 applySingleColumnFilter
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
11
 fetchTotalCount
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 buildSelectColumns
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Grid;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use PDO;
12use Twig\Environment as TwigEnvironment;
13
14/**
15 * Generic Database DataGrid Component Engine.
16 *
17 * Handles parameterized SQL queries, JOINs, dynamic pagination, multi-column sorting, and filters.
18 *
19 * @package App\Core\Grid
20 */
21final readonly class GenericDataGrid
22{
23    /**
24     * GenericDataGrid constructor.
25     *
26     * @param PDO $pdo Database PDO connection.
27     * @param TwigEnvironment $twig Twig template environment.
28     * @param string $tableName Database table name to query (including prefix).
29     * @param array<int, Column> $columns Array of Column definitions.
30     * @param string|null $defaultSort Default column key for sorting.
31     * @param string|null $joinSql Optional JOIN SQL string.
32     * @param string|null $tableAlias Optional main table alias (e.g. 'l').
33     */
34    public function __construct(
35        private PDO $pdo,
36        private TwigEnvironment $twig,
37        private string $tableName,
38        private array $columns,
39        private ?string $defaultSort = 'id',
40        private ?string $joinSql = null,
41        private ?string $tableAlias = null
42    ) {
43    }
44
45    /**
46     * Returns array of column definitions.
47     *
48     * @return array<int, Column> Column list.
49     */
50    public function getColumns(): array
51    {
52        return $this->columns;
53    }
54
55    /**
56     * Renders DataGrid using Twig.
57     *
58     * @param GridRequest $gridRequest Active request state.
59     * @return string Rendered Tabler UI HTML string.
60     */
61    public function render(GridRequest $gridRequest): string
62    {
63        $result = $this->fetchData($gridRequest);
64
65        return $this->renderWithResult($result, $gridRequest);
66    }
67
68    /**
69     * Renders DataGrid with pre-fetched GridResult using Twig.
70     *
71     * @param GridResult $result Pre-fetched GridResult object.
72     * @param GridRequest $gridRequest Active request state.
73     * @return string Rendered Tabler UI HTML string.
74     */
75    public function renderWithResult(GridResult $result, GridRequest $gridRequest): string
76    {
77        return $this->twig->render('shared/grid.twig', [
78            'result' => $result,
79            'columns' => $this->columns,
80            'request' => $gridRequest,
81        ]);
82    }
83
84    /**
85     * Fetches dataset and total record count using parameterized SQL.
86     *
87     * @param GridRequest $gridRequest Active grid request.
88     * @return GridResult DataGrid query result container.
89     */
90    public function fetchData(GridRequest $gridRequest): GridResult
91    {
92        $aliasPrefix = $this->tableAlias !== null ? "`{$this->tableAlias}`." : '';
93        $fromSql = "`{$this->tableName}`" . ($this->tableAlias !== null ? " {$this->tableAlias}" : '');
94        if ($this->joinSql !== null) {
95            $fromSql .= ' ' . $this->joinSql;
96        }
97
98        $validColumns = array_map(fn (Column $c) => $c->key, $this->columns);
99        $sortableColumns = array_map(
100            fn (Column $c) => $c->key,
101            array_filter($this->columns, fn (Column $c) => $c->isSortable)
102        );
103        $filterableColumns = array_map(
104            fn (Column $c) => $c->key,
105            array_filter($this->columns, fn (Column $c) => $c->isFilterable)
106        );
107
108        $columnMap = [];
109        foreach ($this->columns as $col) {
110            $columnMap[$col->key] = $col;
111        }
112
113        [$sortColumn, $sortDirection] = $this->resolveSort($gridRequest, $sortableColumns, $validColumns);
114        [$whereSql, $bindings] = $this->buildWhereClause($gridRequest, $filterableColumns, $columnMap, $aliasPrefix);
115
116        $totalRecords = $this->fetchTotalCount($fromSql, $whereSql, $bindings);
117
118        $offset = ($gridRequest->page - 1) * $gridRequest->limit;
119        $selectCols = $this->buildSelectColumns($aliasPrefix);
120
121        $sortExpr = isset($columnMap[$sortColumn]) && $columnMap[$sortColumn]->selectExpression !== null
122            ? $columnMap[$sortColumn]->selectExpression
123            : "{$aliasPrefix}`{$sortColumn}`";
124
125        $dataSql = "SELECT {$selectCols} FROM {$fromSql}{$whereSql} " .
126            "ORDER BY {$sortExpr} {$sortDirection} LIMIT :limit OFFSET :offset";
127
128        $dataStmt = $this->pdo->prepare($dataSql);
129        foreach ($bindings as $param => $val) {
130            $dataStmt->bindValue($param, $val);
131        }
132        $dataStmt->bindValue(':limit', $gridRequest->limit, PDO::PARAM_INT);
133        $dataStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
134        $dataStmt->execute();
135
136        /** @var array<int, array<string, mixed>> $rows */
137        $rows = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
138
139        return new GridResult($rows, $totalRecords, $gridRequest, $this->columns);
140    }
141
142    /**
143     * Resolves valid sort column and direction.
144     *
145     * @param GridRequest $gridRequest Grid request.
146     * @param array<int, string> $sortableColumns Sortable column keys.
147     * @param array<int, string> $validColumns Valid column keys.
148     * @return array{0: string, 1: string} Resolved sort column and direction.
149     */
150    private function resolveSort(GridRequest $gridRequest, array $sortableColumns, array $validColumns): array
151    {
152        $sortColumn = $gridRequest->sortColumn;
153        if ($sortColumn === null || !in_array($sortColumn, $sortableColumns, true)) {
154            $sortColumn = in_array($this->defaultSort, $sortableColumns, true)
155                ? $this->defaultSort
156        // @codeCoverageIgnoreStart
157                : ($sortableColumns[0] ?? $validColumns[0]);
158        // @codeCoverageIgnoreEnd
159        // @codeCoverageIgnoreStart
160        // @codeCoverageIgnoreEnd
161        }
162        $sortDirection = strtoupper($gridRequest->sortDirection) === 'ASC' ? 'ASC' : 'DESC';
163
164        return [$sortColumn, $sortDirection];
165    }
166
167    /**
168     * Builds SQL WHERE clause and bindings array from grid request filters.
169     *
170     * @param GridRequest $gridRequest Active grid request.
171     * @param array<int, string> $filterableColumns Filterable column keys.
172     * @param array<string, Column> $columnMap Map of column key to Column object.
173     * @param string $aliasPrefix Table alias prefix SQL string.
174     * @return array{0: string, 1: array<string, string>} WHERE SQL snippet and param bindings.
175     */
176    private function buildWhereClause(
177        GridRequest $gridRequest,
178        array $filterableColumns,
179        array $columnMap,
180        string $aliasPrefix
181    ): array {
182        $whereClauses = [];
183        $bindings = [];
184
185        foreach ($gridRequest->filters as $colKey => $filterValue) {
186            $filterValueStr = (string)$filterValue;
187            if (!in_array($colKey, $filterableColumns, true) || trim($filterValueStr) === '') {
188                continue;
189            }
190            $this->applySingleColumnFilter(
191                (string)$colKey,
192                $filterValueStr,
193                $columnMap,
194                $aliasPrefix,
195                $whereClauses,
196                $bindings
197            );
198        }
199
200        $whereSql = !empty($whereClauses) ? ' WHERE ' . implode(' AND ', $whereClauses) : '';
201
202        return [$whereSql, $bindings];
203    }
204
205    /**
206     * Applies filter clause and bindings for a single column.
207     *
208     * @param string $colKey Column key name.
209     * @param string $filterValueStr Filter input value.
210     * @param array<string, Column> $columnMap Map of Column definitions.
211     * @param string $aliasPrefix Table alias SQL string.
212     * @param array<int, string> &$whereClauses Reference to clauses list.
213     * @param array<string, string> &$bindings Reference to PDO bindings array.
214     * @return void
215     */
216    private function applySingleColumnFilter(
217        string $colKey,
218        string $filterValueStr,
219        array $columnMap,
220        string $aliasPrefix,
221        array &$whereClauses,
222        array &$bindings
223    ): void {
224        $paramPlaceholder = ":filter_{$colKey}";
225        $colObj = $columnMap[$colKey] ?? null;
226
227        if ($colObj !== null && $colObj->filterExpression !== null) {
228            $expr = $colObj->filterExpression;
229            $count = 0;
230            $newExpr = (string)preg_replace_callback(
231                '/' . preg_quote($paramPlaceholder, '/') . '(?!\w)/',
232                function () use ($paramPlaceholder, &$count, &$bindings, $filterValueStr, $colObj): string {
233                    $count++;
234                    $uniqueParam = "{$paramPlaceholder}_{$count}";
235                    $isExact = ($colObj->filterType === FilterType::SELECT)
236                        || (!str_contains($colObj->filterExpression ?? '', 'LIKE'));
237                    $bindings[$uniqueParam] = $isExact ? trim($filterValueStr) : '%' . trim($filterValueStr) . '%';
238                    return $uniqueParam;
239                },
240                $expr
241            );
242            $whereClauses[] = $newExpr;
243
244            return;
245        }
246
247        $paramName = "{$paramPlaceholder}_1";
248        $colExpr = $colObj !== null && $colObj->selectExpression !== null
249            ? $colObj->selectExpression
250            : "{$aliasPrefix}`{$colKey}`";
251        $op = ($colObj !== null && $colObj->filterType === FilterType::SELECT) ? '=' : 'LIKE';
252        $whereClauses[] = "{$colExpr} {$op} {$paramName}";
253
254        $isExact = ($colObj !== null && $colObj->filterType === FilterType::SELECT);
255        $bindings[$paramName] = $isExact ? trim($filterValueStr) : '%' . trim($filterValueStr) . '%';
256    }
257
258    /**
259     * Executes count query to fetch total records.
260     *
261     * @param string $fromSql FROM table and JOIN SQL snippet.
262     * @param string $whereSql WHERE SQL snippet.
263     * @param array<string, string> $bindings Parameter bindings.
264     * @return int Total record count.
265     */
266    private function fetchTotalCount(string $fromSql, string $whereSql, array $bindings): int
267    {
268        $countSql = "SELECT COUNT(*) FROM {$fromSql}{$whereSql}";
269        $countStmt = $this->pdo->prepare($countSql);
270        foreach ($bindings as $param => $val) {
271            $countStmt->bindValue($param, $val);
272        }
273        $countStmt->execute();
274
275        return (int)$countStmt->fetchColumn();
276    }
277
278    /**
279     * Builds SELECT column list SQL snippet.
280     *
281     * @param string $aliasPrefix Table alias prefix string.
282     * @return string Formatted SELECT column list SQL string.
283     */
284    private function buildSelectColumns(string $aliasPrefix): string
285    {
286        $selectExprParts = [];
287        foreach ($this->columns as $col) {
288            if ($col->selectExpression !== null) {
289                $selectExprParts[] = "{$col->selectExpression} AS `{$col->key}`";
290            } else {
291                $selectExprParts[] = "{$aliasPrefix}`{$col->key}` AS `{$col->key}`";
292            }
293        }
294
295        return implode(', ', $selectExprParts);
296    }
297}