• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / doctrine / 27071527216

06 Jun 2026 07:20PM UTC coverage: 93.219% (+0.8%) from 92.41%
27071527216

Pull #17

github

web-flow
Merge 7d5aa15dc into d99db3aca
Pull Request #17: Development

210 of 218 new or added lines in 9 files covered. (96.33%)

866 of 929 relevant lines covered (93.22%)

18.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.11
/src/DataTables/Builder.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Daycry\Doctrine\DataTables;
6

7
use Closure;
8
use CodeIgniter\Exceptions\InvalidArgumentException;
9
use Doctrine\DBAL\Query\QueryBuilder;
10
use Doctrine\ORM\QueryBuilder as ORMQueryBuilder;
11
use Doctrine\ORM\Tools\Pagination\Paginator;
12

13
/**
14
 * Builder for DataTables integration with Doctrine ORM/DBAL.
15
 * Enables dynamic pagination, filtering, and ordering of results.
16
 *
17
 * @property bool                         $caseInsensitive   Case-insensitive search
18
 * @property array<string, string>        $columnAliases     Column aliases DataTables => DB
19
 * @property string                       $columnField       Column field ('data' or 'name')
20
 * @property string                       $indexColumn       Index column
21
 * @property ORMQueryBuilder|QueryBuilder $queryBuilder      Doctrine QueryBuilder
22
 * @property array<string, mixed>|null    $requestParams     DataTables request parameters
23
 * @property list<string>                 $searchableColumns Columns allowed for global LIKE search
24
 * @property bool                         $useOutputWalkers  Use OutputWalkers in paginator
25
 */
26
class Builder
27
{
28
    /**
29
     * Column aliases DataTables => DB
30
     *
31
     * @var array<string, string>
32
     */
33
    protected array $columnAliases = [];
34

35
    /**
36
     * Column field ('data' or 'name')
37
     */
38
    protected string $columnField = 'data';
39

40
    /**
41
     * Index column
42
     */
43
    protected string $indexColumn = '*';
44

45
    /**
46
     * Case-insensitive search
47
     */
48
    protected bool $caseInsensitive = false;
49

50
    /**
51
     * Doctrine QueryBuilder
52
     */
53
    protected ORMQueryBuilder|QueryBuilder|null $queryBuilder = null;
54

55
    /**
56
     * DataTables request parameters
57
     *
58
     * @var array<string, mixed>|null
59
     */
60
    protected ?array $requestParams = null;
61

62
    /**
63
     * Use OutputWalkers in paginator
64
     */
65
    protected ?bool $useOutputWalkers = null;
66

67
    /**
68
     * Columns allowed for global LIKE search
69
     *
70
     * @var list<string>
71
     */
72
    protected array $searchableColumns = [];
73

74
    /**
75
     * Maximum number of values allowed for IN and OR filter operators.
76
     * Prevents DoS via excessively large filter value lists.
77
     */
78
    protected int $maxFilterValues = 500;
79

80
    /**
81
     * Hard cap on the page size (DataTables `length`). When greater than 0, any
82
     * request asking for more rows than this — including DataTables' "All"
83
     * sentinel `length=-1`, which otherwise produces an unbounded result set —
84
     * is clamped to this value. 0 (default) keeps the legacy behaviour (no cap).
85
     */
86
    protected int $maxPageLength = 0;
87

88
    /**
89
     * Whether the data Paginator fetches to-many collections via an id sub-query
90
     * (`fetchJoinCollection`). Defaults to true (Doctrine's safe default). Set to
91
     * false when the query has no to-many fetch join to collapse the page fetch
92
     * from two queries (id sub-query + WHERE-IN) into one. Count queries are
93
     * unaffected by this flag.
94
     */
95
    protected bool $fetchJoinCollection = true;
96

97
    /**
98
     * Optional precomputed unfiltered total. When set (an int or a Closure
99
     * returning an int), getRecordsTotal()/getResponse() return it instead of
100
     * issuing a COUNT query — useful to cache an expensive total across draws.
101
     */
102
    protected Closure|int|null $recordsTotal = null;
103

104
    /**
105
     * Static factory for fluent usage.
106
     */
107
    public static function create(): self
26✔
108
    {
109
        return new self();
26✔
110
    }
111

112
    /**
113
     * Set columns allowed for global LIKE search.
114
     *
115
     * @param list<string> $columns
116
     *
117
     * @return $this
118
     */
119
    public function withSearchableColumns(array $columns): static
4✔
120
    {
121
        $this->searchableColumns = $columns;
4✔
122

123
        return $this;
4✔
124
    }
125

126
    /**
127
     * Set the maximum number of values accepted by IN and OR filter operators.
128
     * Use PHP_INT_MAX to disable the limit.
129
     */
130
    public function withMaxFilterValues(int $maxFilterValues): static
7✔
131
    {
132
        if ($maxFilterValues < 1) {
7✔
133
            throw new InvalidArgumentException('maxFilterValues must be at least 1. Use PHP_INT_MAX to disable the limit.');
2✔
134
        }
135

136
        $this->maxFilterValues = $maxFilterValues;
5✔
137

138
        return $this;
5✔
139
    }
140

141
    /**
142
     * Set a hard upper bound on the page size returned to the client.
143
     *
144
     * Protects against resource exhaustion from `length=-1` ("All") or an
145
     * arbitrarily large `length`, which would otherwise hydrate the entire
146
     * filtered table. Pass 0 to disable the cap (legacy behaviour).
147
     */
148
    public function withMaxPageLength(int $maxPageLength): static
1✔
149
    {
150
        if ($maxPageLength < 0) {
1✔
NEW
151
            throw new InvalidArgumentException('maxPageLength must be 0 (disabled) or a positive integer.');
×
152
        }
153

154
        $this->maxPageLength = $maxPageLength;
1✔
155

156
        return $this;
1✔
157
    }
158

159
    /**
160
     * Set whether the paginated data fetch uses `fetchJoinCollection`.
161
     * Leave true (default) for queries that fetch-join a to-many association;
162
     * set false for the common scalar/single-entity SELECT to save one query.
163
     */
164
    public function withFetchJoinCollection(bool $fetchJoinCollection): static
1✔
165
    {
166
        $this->fetchJoinCollection = $fetchJoinCollection;
1✔
167

168
        return $this;
1✔
169
    }
170

171
    /**
172
     * Provide a precomputed unfiltered total (int or a Closure returning int) so
173
     * getRecordsTotal()/getResponse() skip the COUNT query. Invalidation is the
174
     * caller's responsibility.
175
     */
176
    public function withRecordsTotal(Closure|int $recordsTotal): static
2✔
177
    {
178
        $this->recordsTotal = $recordsTotal;
2✔
179

180
        return $this;
2✔
181
    }
182

183
    /**
184
     * Returns paginated, filtered, and ordered data for DataTables.
185
     *
186
     * @return list<object>
187
     *
188
     * @throws InvalidArgumentException
189
     */
190
    public function getData(): array
10✔
191
    {
192
        $this->validate();
10✔
193
        $query   = $this->getFilteredQuery();
7✔
194
        $columns = $this->requestParams['columns'];
5✔
195
        $this->applyOrdering($query, $columns);
5✔
196
        $this->applyPagination($query);
5✔
197
        $paginator = new Paginator($query, $this->fetchJoinCollection);
5✔
198
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
5✔
199
        $result = [];
5✔
200

201
        foreach ($paginator as $obj) {
5✔
202
            $result[] = $obj;
4✔
203
        }
204

205
        return $result;
5✔
206
    }
207

208
    /**
209
     * Returns a filtered QueryBuilder based on DataTables parameters.
210
     *
211
     * @throws InvalidArgumentException
212
     */
213
    public function getFilteredQuery(): ORMQueryBuilder|QueryBuilder
30✔
214
    {
215
        $this->validate();
30✔
216
        $query   = clone $this->queryBuilder;
30✔
217
        $columns = $this->requestParams['columns'];
30✔
218
        $c       = count($columns);
30✔
219

220
        // Reject regex search mode only when it would actually be applied (non-empty value).
221
        // DataTables clients commonly send `regex` keys for every column even when the value is
222
        // empty; raising on empty values would force callers to strip noise from their payload.
223
        $globalSearchValue = trim((string) ($this->requestParams['search']['value'] ?? ''));
30✔
224
        if ($globalSearchValue !== '' && ! empty($this->requestParams['search']['regex'])) {
30✔
225
            throw new InvalidArgumentException('Regex search is not supported. Use bracket-prefix operators like [IN], [OR], [><] instead.');
1✔
226
        }
227

228
        foreach ($columns as $column) {
29✔
229
            $columnSearchValue = trim((string) ($column['search']['value'] ?? ''));
29✔
230
            if ($columnSearchValue !== '' && ! empty($column['search']['regex'] ?? null)) {
29✔
231
                throw new InvalidArgumentException('Regex per-column search is not supported. Use bracket-prefix operators like [IN], [OR], [><] instead.');
1✔
232
            }
233
        }
234

235
        // Search
236
        if (array_key_exists('search', $this->requestParams)) {
28✔
237
            $value = mb_substr(trim($this->requestParams['search']['value'] ?? ''), 0, 255);
21✔
238
            if ($value !== '') {
21✔
239
                $orX = $query->expr()->orX();
4✔
240

241
                for ($i = 0; $i < $c; $i++) {
4✔
242
                    $column = $columns[$i];
4✔
243
                    if ($this->isColumnSearchable($column)) {
4✔
244
                        $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', $i);
4✔
245

246
                        // Only allow LIKE on configured searchable columns
247
                        if (! empty($this->searchableColumns) && ! in_array($fieldName, $this->searchableColumns, true)) {
4✔
248
                            continue;
2✔
249
                        }
250

251
                        // Skip if field is not valid for DQL (prevents numeric indices and invalid identifiers)
252
                        if (! $this->isValidDQLField($fieldName)) {
4✔
253
                            continue;
×
254
                        }
255

256
                        if ($this->caseInsensitive) {
4✔
257
                            $searchColumn = 'lower(' . $fieldName . ')';
3✔
258
                            $orX->add($query->expr()->like($searchColumn, 'lower(:search)'));
3✔
259
                        } else {
260
                            $orX->add($query->expr()->like($fieldName, ':search'));
1✔
261
                        }
262
                    }
263
                }
264
                if ($orX->count() >= 1) {
4✔
265
                    $query->andWhere($orX)
4✔
266
                        ->setParameter('search', "%{$value}%");
4✔
267
                }
268
            }
269
        }
270

271
        // Filter
272
        for ($i = 0; $i < $c; $i++) {
28✔
273
            $column = $columns[$i];
28✔
274
            $andX   = $query->expr()->andX();
28✔
275
            $value  = trim($column['search']['value'] ?? '');
28✔
276
            if ($this->isColumnSearchable($column) && $value !== '') {
28✔
277
                $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', $i);
18✔
278

279
                // Skip if field is not valid for DQL (prevents numeric indices and invalid identifiers)
280
                if (! $this->isValidDQLField($fieldName)) {
18✔
281
                    continue;
×
282
                }
283

284
                // Parse operator and value via helper for maintainability
285
                [$operator, $value] = $this->parseFilterOperator($value);
18✔
286
                if ($this->caseInsensitive) {
18✔
287
                    $searchColumn = 'lower(' . $fieldName . ')';
3✔
288
                    $filter       = "lower(:filter_{$i})";
3✔
289
                } else {
290
                    $searchColumn = $fieldName;
15✔
291
                    $filter       = ":filter_{$i}";
15✔
292
                }
293

294
                switch ($operator) {
295
                    case '!=':
18✔
296
                        $andX->add($query->expr()->neq($searchColumn, $filter));
1✔
297
                        $query->setParameter("filter_{$i}", $value);
1✔
298
                        break;
1✔
299

300
                    case '<':
17✔
301
                        $andX->add($query->expr()->lt($searchColumn, $filter));
1✔
302
                        $query->setParameter("filter_{$i}", $value);
1✔
303
                        break;
1✔
304

305
                    case '>':
16✔
306
                        $andX->add($query->expr()->gt($searchColumn, $filter));
1✔
307
                        $query->setParameter("filter_{$i}", $value);
1✔
308
                        break;
1✔
309

310
                    case 'IN':
15✔
311
                        $valueArr = explode(',', $value);
4✔
312
                        if (count($valueArr) > $this->maxFilterValues) {
4✔
313
                            throw new InvalidArgumentException(sprintf(
1✔
314
                                'IN filter exceeds maximum allowed values (%d). Got %d. Use withMaxFilterValues() to adjust the limit.',
1✔
315
                                $this->maxFilterValues,
1✔
316
                                count($valueArr),
1✔
317
                            ));
1✔
318
                        }
319
                        $params = [];
3✔
320

321
                        for ($j = 0; $j < count($valueArr); $j++) {
3✔
322
                            $params[] = ":filter_{$i}_{$j}";
3✔
323
                        }
324
                        $andX->add($query->expr()->in($fieldName, implode(',', $params)));
3✔
325

326
                        for ($j = 0; $j < count($valueArr); $j++) {
3✔
327
                            $query->setParameter("filter_{$i}_{$j}", trim($valueArr[$j]));
3✔
328
                        }
329
                        break;
3✔
330

331
                    case 'OR':
12✔
332
                        $valueArr = explode(',', $value);
4✔
333
                        if (count($valueArr) > $this->maxFilterValues) {
4✔
334
                            throw new InvalidArgumentException(sprintf(
1✔
335
                                'OR filter exceeds maximum allowed values (%d). Got %d. Use withMaxFilterValues() to adjust the limit.',
1✔
336
                                $this->maxFilterValues,
1✔
337
                                count($valueArr),
1✔
338
                            ));
1✔
339
                        }
340
                        $orX = $query->expr()->orX();
3✔
341

342
                        for ($j = 0; $j < count($valueArr); $j++) {
3✔
343
                            // Honor caseInsensitive: $searchColumn is already lower()-wrapped
344
                            // when the flag is on; wrap each placeholder to match.
345
                            $orPlaceholder = $this->caseInsensitive ? "lower(:filter_{$i}_{$j})" : ":filter_{$i}_{$j}";
3✔
346
                            $orX->add($query->expr()->like($searchColumn, $orPlaceholder));
3✔
347
                        }
348
                        $andX->add($orX);
3✔
349

350
                        for ($j = 0; $j < count($valueArr); $j++) {
3✔
351
                            $query->setParameter("filter_{$i}_{$j}", '%' . trim($valueArr[$j]) . '%');
3✔
352
                        }
353
                        break;
3✔
354

355
                    case '><':
9✔
356
                        $valueArr = explode(',', $value);
4✔
357
                        if (count($valueArr) !== 2) {
4✔
358
                            throw new InvalidArgumentException(sprintf(
2✔
359
                                'BETWEEN operator [><] requires exactly 2 comma-separated values, got %d.',
2✔
360
                                count($valueArr),
2✔
361
                            ));
2✔
362
                        }
363
                        $andX->add($query->expr()->between($fieldName, ":filter_{$i}_0", ":filter_{$i}_1"));
2✔
364
                        $query->setParameter("filter_{$i}_0", trim($valueArr[0]));
2✔
365
                        $query->setParameter("filter_{$i}_1", trim($valueArr[1]));
2✔
366
                        break;
2✔
367

368
                    case '=':
5✔
369
                        $andX->add($query->expr()->eq($searchColumn, $filter));
1✔
370
                        $query->setParameter("filter_{$i}", $value);
1✔
371
                        break;
1✔
372

373
                    case '%':
4✔
374
                    default:
375
                        $andX->add($query->expr()->like($searchColumn, $filter));
4✔
376
                        $query->setParameter("filter_{$i}", "%{$value}%");
4✔
377
                        break;
4✔
378
                }
379
            }
380
            if ($andX->count() >= 1) {
24✔
381
                $query->andWhere($andX);
14✔
382
            }
383
        }
384

385
        return $query;
24✔
386
    }
387

388
    /**
389
     * Parse a raw filter value extracting the operator and cleaned term.
390
     * Returns [operator, value] with fallback to '%'.
391
     * Supported operators: !=, <, >, IN, OR, ><, =, %, LIKE, %% (LIKE/%% normalize to %).
392
     *
393
     * @return array{0: string, 1: string}
394
     */
395
    private function parseFilterOperator(string $raw): array
18✔
396
    {
397
        $validPattern  = '~^\[(?<operator>!=|><|>|<|=|%%|%|IN|OR|LIKE)\]~i';
18✔
398
        $bracketPrefix = '~^\[[^\]]+\]~';
18✔
399

400
        if (preg_match($validPattern, $raw, $m)) {
18✔
401
            $operator = strtoupper($m['operator']);
16✔
402
            $value    = preg_replace($validPattern, '', $raw);
16✔
403
        } else {
404
            // Unknown bracket prefixes (typos like "[XYZ]") fall back to LIKE; the
405
            // prefix itself is stripped so the user-facing search term works as-is.
406
            $operator = '%';
2✔
407
            $value    = preg_replace($bracketPrefix, '', $raw);
2✔
408
        }
409

410
        // Normalize synonyms
411
        if ($operator === 'LIKE' || $operator === '%%') {
18✔
412
            $operator = '%';
2✔
413
        }
414

415
        return [$operator, trim($value ?? $raw)];
18✔
416
    }
417

418
    /**
419
     * Returns the number of filtered records.
420
     */
421
    public function getRecordsFiltered(): int
1✔
422
    {
423
        $query     = $this->getFilteredQuery();
1✔
424
        $paginator = new Paginator($query, $fetchJoinCollection = true);
1✔
425
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
1✔
426

427
        return $paginator->count();
1✔
428
    }
429

430
    /**
431
     * Returns the total number of records (without filters).
432
     */
433
    public function getRecordsTotal(): int
2✔
434
    {
435
        $injected = $this->resolveInjectedRecordsTotal();
2✔
436
        if ($injected !== null) {
2✔
437
            return $injected;
1✔
438
        }
439

440
        $this->validate();
1✔
441
        $query     = clone $this->queryBuilder;
1✔
442
        $paginator = new Paginator($query, $fetchJoinCollection = true);
1✔
443
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
1✔
444

445
        return $paginator->count();
1✔
446
    }
447

448
    /**
449
     * Resolve the injected unfiltered total, or null when none was provided.
450
     */
451
    private function resolveInjectedRecordsTotal(): ?int
19✔
452
    {
453
        if ($this->recordsTotal === null) {
19✔
454
            return null;
17✔
455
        }
456

457
        return (int) ($this->recordsTotal instanceof Closure ? ($this->recordsTotal)() : $this->recordsTotal);
2✔
458
    }
459

460
    /**
461
     * Returns the DataTables response array.
462
     *
463
     * @return array<string, mixed>
464
     */
465
    public function getResponse(): array
17✔
466
    {
467
        $this->validate();
17✔
468
        $filteredQuery = $this->getFilteredQuery();
17✔
469
        $columns       = $this->requestParams['columns'];
17✔
470

471
        // Data (with ordering + pagination)
472
        $dataQuery = clone $filteredQuery;
17✔
473
        $this->applyOrdering($dataQuery, $columns);
17✔
474
        $this->applyPagination($dataQuery);
17✔
475
        $dataPaginator = new Paginator($dataQuery, $this->fetchJoinCollection);
17✔
476
        $dataPaginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
17✔
477
        $data = [];
17✔
478

479
        foreach ($dataPaginator as $obj) {
17✔
480
            $data[] = $obj;
17✔
481
        }
482

483
        // Filtered count (reuses the already-built filtered query)
484
        $filteredPaginator = new Paginator($filteredQuery, true);
17✔
485
        $filteredPaginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
17✔
486

487
        // Total count (unfiltered) — use the injected/cached total when provided,
488
        // otherwise compute it via a Paginator.
489
        $recordsTotal = $this->resolveInjectedRecordsTotal();
17✔
490
        if ($recordsTotal === null) {
17✔
491
            $totalQuery     = clone $this->queryBuilder;
16✔
492
            $totalPaginator = new Paginator($totalQuery, true);
16✔
493
            $totalPaginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
16✔
494
            $recordsTotal = $totalPaginator->count();
16✔
495
        }
496

497
        return [
17✔
498
            'data'            => $data,
17✔
499
            'draw'            => $this->requestParams['draw'] ?? 0,
17✔
500
            'recordsFiltered' => $filteredPaginator->count(),
17✔
501
            'recordsTotal'    => $recordsTotal,
17✔
502
        ];
17✔
503
    }
504

505
    /**
506
     * Sets the index column.
507
     */
508
    public function withIndexColumn(string $indexColumn): static
16✔
509
    {
510
        $this->indexColumn = $indexColumn;
16✔
511

512
        return $this;
16✔
513
    }
514

515
    /**
516
     * Sets useOutputWalkers for the paginator.
517
     */
518
    public function setUseOutputWalkers(bool $useOutputWalkers): static
25✔
519
    {
520
        $this->useOutputWalkers = $useOutputWalkers;
25✔
521

522
        return $this;
25✔
523
    }
524

525
    /**
526
     * Sets column aliases.
527
     *
528
     * @param array<string, string> $columnAliases
529
     */
530
    public function withColumnAliases(array $columnAliases): static
31✔
531
    {
532
        $this->columnAliases = $columnAliases;
31✔
533

534
        return $this;
31✔
535
    }
536

537
    /**
538
     * Enables or disables case-insensitive search.
539
     */
540
    public function withCaseInsensitive(bool $caseInsensitive): static
17✔
541
    {
542
        $this->caseInsensitive = $caseInsensitive;
17✔
543

544
        return $this;
17✔
545
    }
546

547
    /**
548
     * Sets the column field ('data' or 'name').
549
     */
550
    public function withColumnField(string $columnField): static
17✔
551
    {
552
        $this->columnField = $columnField;
17✔
553

554
        return $this;
17✔
555
    }
556

557
    /**
558
     * Sets the Doctrine QueryBuilder.
559
     */
560
    public function withQueryBuilder(ORMQueryBuilder|QueryBuilder $queryBuilder): static
34✔
561
    {
562
        $this->queryBuilder = $queryBuilder;
34✔
563

564
        return $this;
34✔
565
    }
566

567
    /**
568
     * Sets the DataTables request parameters.
569
     *
570
     * @param array<string, mixed> $requestParams
571
     */
572
    public function withRequestParams(array $requestParams): static
35✔
573
    {
574
        $this->requestParams = $requestParams;
35✔
575

576
        return $this;
35✔
577
    }
578

579
    /**
580
     * Validates that required properties are set.
581
     */
582
    protected function validate(): void
34✔
583
    {
584
        if ($this->queryBuilder === null) {
34✔
585
            throw new InvalidArgumentException('QueryBuilder is not set.');
1✔
586
        }
587
        if (! is_array($this->requestParams) || empty($this->requestParams['columns'])) {
33✔
588
            throw new InvalidArgumentException('Request parameters or columns are not set.');
2✔
589
        }
590
    }
591

592
    /**
593
     * Applies ordering to the query.
594
     *
595
     * @param array<int|string, mixed> $columns
596
     */
597
    protected function applyOrdering(ORMQueryBuilder|QueryBuilder $query, array $columns): void
22✔
598
    {
599
        if (array_key_exists('order', $this->requestParams)) {
22✔
600
            $order = $this->requestParams['order'];
19✔
601

602
            foreach ($order as $sort) {
19✔
603
                // Skip entries whose client-supplied column index is missing or
604
                // out of range, instead of warning on an undefined array key.
605
                if (! isset($sort['column'])) {
19✔
NEW
606
                    continue;
×
607
                }
608
                $columnIndex = (int) $sort['column'];
19✔
609
                if (! array_key_exists($columnIndex, $columns)) {
19✔
610
                    continue;
1✔
611
                }
612

613
                $column    = $columns[$columnIndex];
18✔
614
                $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', $columnIndex);
18✔
615
                $dir       = strtoupper($sort['dir'] ?? 'ASC');
18✔
616
                $dir       = in_array($dir, ['ASC', 'DESC'], true) ? $dir : 'ASC';
18✔
617

618
                // Only add ordering if field is valid for DQL
619
                if ($this->isValidDQLField($fieldName)) {
18✔
620
                    $query->addOrderBy($fieldName, $dir);
17✔
621
                }
622
            }
623
        }
624
    }
625

626
    /**
627
     * Applies offset and limit to the query.
628
     */
629
    protected function applyPagination(ORMQueryBuilder|QueryBuilder $query): void
22✔
630
    {
631
        if (array_key_exists('start', $this->requestParams)) {
22✔
632
            $query->setFirstResult((int) ($this->requestParams['start']));
22✔
633
        }
634

635
        $length = array_key_exists('length', $this->requestParams)
22✔
636
            ? (int) ($this->requestParams['length'])
22✔
NEW
637
            : 0;
×
638

639
        if ($this->maxPageLength > 0) {
22✔
640
            // Clamp unbounded ("All"/length<=0) or oversized requests to the cap.
641
            $effective = ($length <= 0 || $length > $this->maxPageLength) ? $this->maxPageLength : $length;
1✔
642
            $query->setMaxResults($effective);
1✔
643
        } elseif ($length > 0) {
21✔
644
            $query->setMaxResults($length);
21✔
645
        }
646
    }
647

648
    /**
649
     * Helper: Check if a column is searchable.
650
     * Accepts both boolean true and string 'true'.
651
     *
652
     * @param array<string, mixed> $column
653
     */
654
    protected function isColumnSearchable(array $column): bool
31✔
655
    {
656
        return
31✔
657
            (isset($column['searchable']) && ($column['searchable'] === true || $column['searchable'] === 'true'))
31✔
658
            && isset($column[$this->columnField]) && $column[$this->columnField] !== '';
31✔
659
    }
660

661
    /**
662
     * Helper: Resolve column alias if set.
663
     */
664
    protected function resolveColumnAlias(string $field): string
31✔
665
    {
666
        return $this->columnAliases[$field] ?? $field;
31✔
667
    }
668

669
    /**
670
     * Helper: Resolve field name for DQL, handling DataTables column configuration.
671
     *
672
     * @param mixed $columnValue The column value from DataTables (could be field name or index)
673
     * @param int   $columnIndex The column index as fallback
674
     *
675
     * @return string The resolved field name
676
     */
677
    protected function resolveFieldName($columnValue, int $columnIndex): string
33✔
678
    {
679
        // If columnValue is numeric or empty, it's likely an index, not a field name
680
        if (is_numeric($columnValue) || empty($columnValue)) {
33✔
681
            return (string) $columnIndex; // Return as string to be caught by isValidDQLField
9✔
682
        }
683

684
        // Resolve alias if exists
685
        return $this->resolveColumnAlias((string) $columnValue);
31✔
686
    }
687

688
    /**
689
     * Helper: Check if field name is valid for DQL queries.
690
     * Prevents numeric indices and invalid identifiers from being used in DQL.
691
     *
692
     * @param string $field The field name to validate
693
     *
694
     * @return bool True if field is valid for DQL, false otherwise
695
     */
696
    protected function isValidDQLField(string $field): bool
32✔
697
    {
698
        // Must match valid DQL identifier pattern (letters, numbers, underscore, dots for joins)
699
        // Must not be purely numeric
700
        return ! empty($field)
32✔
701
            && ! is_numeric($field)
32✔
702
            && (bool) preg_match('/^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*$/', $field);
32✔
703
    }
704
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc