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

daycry / doctrine / 24675033129

20 Apr 2026 03:26PM UTC coverage: 78.027% (+1.9%) from 76.142%
24675033129

push

github

daycry
Fixes & improvements

Fixes & improvements

122 of 157 new or added lines in 8 files covered. (77.71%)

51 existing lines in 5 files now uncovered.

522 of 669 relevant lines covered (78.03%)

10.3 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace Daycry\Doctrine\DataTables;
6

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

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

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

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

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

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

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

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

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

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

79
    /**
80
     * Static factory for fluent usage.
81
     */
82
    public static function create(): self
17✔
83
    {
84
        return new self();
17✔
85
    }
86

87
    /**
88
     * Set columns allowed for global LIKE search.
89
     *
90
     * @param list<string> $columns
91
     *
92
     * @return $this
93
     */
94
    public function withSearchableColumns(array $columns): static
3✔
95
    {
96
        $this->searchableColumns = $columns;
3✔
97

98
        return $this;
3✔
99
    }
100

101
    /**
102
     * Set the maximum number of values accepted by IN and OR filter operators.
103
     * Use PHP_INT_MAX to disable the limit.
104
     */
NEW
105
    public function withMaxFilterValues(int $maxFilterValues): static
×
106
    {
NEW
107
        if ($maxFilterValues < 1) {
×
NEW
108
            throw new InvalidArgumentException('maxFilterValues must be at least 1. Use PHP_INT_MAX to disable the limit.');
×
109
        }
110

NEW
111
        $this->maxFilterValues = $maxFilterValues;
×
112

NEW
113
        return $this;
×
114
    }
115

116
    /**
117
     * Returns paginated, filtered, and ordered data for DataTables.
118
     *
119
     * @return list<object>
120
     *
121
     * @throws InvalidArgumentException
122
     */
123
    public function getData(): array
3✔
124
    {
125
        $this->validate();
3✔
UNCOV
126
        $query   = $this->getFilteredQuery();
×
UNCOV
127
        $columns = $this->requestParams['columns'];
×
UNCOV
128
        $this->applyOrdering($query, $columns);
×
UNCOV
129
        $this->applyPagination($query);
×
UNCOV
130
        $paginator = new Paginator($query, $fetchJoinCollection = true);
×
UNCOV
131
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
×
UNCOV
132
        $result = [];
×
133

UNCOV
134
        foreach ($paginator as $obj) {
×
UNCOV
135
            $result[] = $obj;
×
136
        }
137

UNCOV
138
        return $result;
×
139
    }
140

141
    /**
142
     * Returns a filtered QueryBuilder based on DataTables parameters.
143
     *
144
     * @throws InvalidArgumentException
145
     */
146
    public function getFilteredQuery(): ORMQueryBuilder|QueryBuilder
15✔
147
    {
148
        $this->validate();
15✔
149
        $query   = clone $this->queryBuilder;
15✔
150
        $columns = $this->requestParams['columns'];
15✔
151
        $c       = count($columns);
15✔
152

153
        // Search
154
        if (array_key_exists('search', $this->requestParams)) {
15✔
155
            $value = mb_substr(trim($this->requestParams['search']['value'] ?? ''), 0, 255);
15✔
156
            if ($value !== '') {
15✔
157
                $orX = $query->expr()->orX();
3✔
158

159
                for ($i = 0; $i < $c; $i++) {
3✔
160
                    $column = $columns[$i];
3✔
161
                    if ($this->isColumnSearchable($column)) {
3✔
162
                        $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', $i);
3✔
163

164
                        // Only allow LIKE on configured searchable columns
165
                        if (! empty($this->searchableColumns) && ! in_array($fieldName, $this->searchableColumns, true)) {
3✔
166
                            continue;
2✔
167
                        }
168

169
                        // Skip if field is not valid for DQL (prevents numeric indices and invalid identifiers)
170
                        if (! $this->isValidDQLField($fieldName)) {
3✔
171
                            continue;
×
172
                        }
173

174
                        if ($this->caseInsensitive) {
3✔
175
                            $searchColumn = 'lower(' . $fieldName . ')';
3✔
176
                            $orX->add($query->expr()->like($searchColumn, 'lower(:search)'));
3✔
177
                        } else {
178
                            $orX->add($query->expr()->like($fieldName, ':search'));
×
179
                        }
180
                    }
181
                }
182
                if ($orX->count() >= 1) {
3✔
183
                    $query->andWhere($orX)
3✔
184
                        ->setParameter('search', "%{$value}%");
3✔
185
                }
186
            }
187
        }
188

189
        // Filter
190
        for ($i = 0; $i < $c; $i++) {
15✔
191
            $column = $columns[$i];
15✔
192
            $andX   = $query->expr()->andX();
15✔
193
            $value  = trim($column['search']['value'] ?? '');
15✔
194
            if ($this->isColumnSearchable($column) && $value !== '') {
15✔
195
                $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', $i);
13✔
196

197
                // Skip if field is not valid for DQL (prevents numeric indices and invalid identifiers)
198
                if (! $this->isValidDQLField($fieldName)) {
13✔
199
                    continue;
×
200
                }
201

202
                // Parse operator and value via helper for maintainability
203
                [$operator, $value] = $this->parseFilterOperator($value);
13✔
204
                if ($this->caseInsensitive) {
13✔
205
                    $searchColumn = 'lower(' . $fieldName . ')';
2✔
206
                    $filter       = "lower(:filter_{$i})";
2✔
207
                } else {
208
                    $searchColumn = $fieldName;
11✔
209
                    $filter       = ":filter_{$i}";
11✔
210
                }
211

212
                switch ($operator) {
213
                    case '!=':
13✔
214
                        $andX->add($query->expr()->neq($searchColumn, $filter));
1✔
215
                        $query->setParameter("filter_{$i}", $value);
1✔
216
                        break;
1✔
217

218
                    case '<':
12✔
219
                        $andX->add($query->expr()->lt($searchColumn, $filter));
1✔
220
                        $query->setParameter("filter_{$i}", $value);
1✔
221
                        break;
1✔
222

223
                    case '>':
11✔
224
                        $andX->add($query->expr()->gt($searchColumn, $filter));
1✔
225
                        $query->setParameter("filter_{$i}", $value);
1✔
226
                        break;
1✔
227

228
                    case 'IN':
10✔
229
                        $valueArr = explode(',', $value);
3✔
230
                        if (count($valueArr) > $this->maxFilterValues) {
3✔
NEW
231
                            throw new InvalidArgumentException(sprintf(
×
NEW
232
                                'IN filter exceeds maximum allowed values (%d). Got %d. Use withMaxFilterValues() to adjust the limit.',
×
NEW
233
                                $this->maxFilterValues,
×
NEW
234
                                count($valueArr),
×
NEW
235
                            ));
×
236
                        }
237
                        $params = [];
3✔
238

239
                        for ($j = 0; $j < count($valueArr); $j++) {
3✔
240
                            $params[] = ":filter_{$i}_{$j}";
3✔
241
                        }
242
                        $andX->add($query->expr()->in($fieldName, implode(',', $params)));
3✔
243

244
                        for ($j = 0; $j < count($valueArr); $j++) {
3✔
245
                            $query->setParameter("filter_{$i}_{$j}", trim($valueArr[$j]));
3✔
246
                        }
247
                        break;
3✔
248

249
                    case 'OR':
8✔
250
                        $valueArr = explode(',', $value);
2✔
251
                        if (count($valueArr) > $this->maxFilterValues) {
2✔
NEW
252
                            throw new InvalidArgumentException(sprintf(
×
NEW
253
                                'OR filter exceeds maximum allowed values (%d). Got %d. Use withMaxFilterValues() to adjust the limit.',
×
NEW
254
                                $this->maxFilterValues,
×
NEW
255
                                count($valueArr),
×
NEW
256
                            ));
×
257
                        }
258
                        $orX = $query->expr()->orX();
2✔
259

260
                        for ($j = 0; $j < count($valueArr); $j++) {
2✔
261
                            $orX->add($query->expr()->like($fieldName, ":filter_{$i}_{$j}"));
2✔
262
                        }
263
                        $andX->add($orX);
2✔
264

265
                        for ($j = 0; $j < count($valueArr); $j++) {
2✔
266
                            $query->setParameter("filter_{$i}_{$j}", '%' . trim($valueArr[$j]) . '%');
2✔
267
                        }
268
                        break;
2✔
269

270
                    case '><':
7✔
271
                        $valueArr = explode(',', $value);
2✔
272
                        if (count($valueArr) === 2) {
2✔
273
                            $andX->add($query->expr()->between($fieldName, ":filter_{$i}_0", ":filter_{$i}_1"));
2✔
274
                            $query->setParameter("filter_{$i}_0", trim($valueArr[0]));
2✔
275
                            $query->setParameter("filter_{$i}_1", trim($valueArr[1]));
2✔
276
                        }
277
                        break;
2✔
278

279
                    case '=':
5✔
280
                        $andX->add($query->expr()->eq($searchColumn, $filter));
1✔
281
                        $query->setParameter("filter_{$i}", $value);
1✔
282
                        break;
1✔
283

284
                    case '%':
4✔
285
                    default:
286
                        $andX->add($query->expr()->like($searchColumn, $filter));
4✔
287
                        $query->setParameter("filter_{$i}", "%{$value}%");
4✔
288
                        break;
4✔
289
                }
290
            }
291
            if ($andX->count() >= 1) {
15✔
292
                $query->andWhere($andX);
13✔
293
            }
294
        }
295

296
        return $query;
15✔
297
    }
298

299
    /**
300
     * Parse a raw filter value extracting the operator and cleaned term.
301
     * Returns [operator, value] with fallback to '%'.
302
     * Supported operators: !=, <, >, IN, OR, ><, =, %, LIKE, %% (LIKE/%% normalize to %).
303
     *
304
     * @return array{0: string, 1: string}
305
     */
306
    private function parseFilterOperator(string $raw): array
13✔
307
    {
308
        $operator = preg_match('~^\[(?<operator>[A-Z!=%<>•]+)\]~i', $raw, $m) ? strtoupper($m['operator']) : '%';
13✔
309
        $value    = preg_replace('~^\[[A-Z!=%<>•]+\]~i', '', $raw);
13✔
310
        // Normalize synonyms
311
        if (in_array($operator, ['LIKE', '%%'], true)) {
13✔
312
            $operator = '%';
2✔
313
        }
314
        $valid = ['!=', '<', '>', 'IN', 'OR', '><', '=', '%'];
13✔
315
        if (! in_array($operator, $valid, true)) {
13✔
316
            $operator = '%';
1✔
317
        }
318

319
        return [$operator, trim($value)];
13✔
320
    }
321

322
    /**
323
     * Returns the number of filtered records.
324
     */
UNCOV
325
    public function getRecordsFiltered(): int
×
326
    {
UNCOV
327
        $query     = $this->getFilteredQuery();
×
UNCOV
328
        $paginator = new Paginator($query, $fetchJoinCollection = true);
×
UNCOV
329
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
×
330

UNCOV
331
        return $paginator->count();
×
332
    }
333

334
    /**
335
     * Returns the total number of records (without filters).
336
     */
UNCOV
337
    public function getRecordsTotal(): int
×
338
    {
UNCOV
339
        $this->validate();
×
UNCOV
340
        $query     = clone $this->queryBuilder;
×
UNCOV
341
        $paginator = new Paginator($query, $fetchJoinCollection = true);
×
UNCOV
342
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
×
343

UNCOV
344
        return $paginator->count();
×
345
    }
346

347
    /**
348
     * Returns the DataTables response array.
349
     *
350
     * @return array<string, mixed>
351
     */
352
    public function getResponse(): array
15✔
353
    {
354
        $this->validate();
15✔
355
        $filteredQuery = $this->getFilteredQuery();
15✔
356
        $columns       = $this->requestParams['columns'];
15✔
357

358
        // Data (with ordering + pagination)
359
        $dataQuery = clone $filteredQuery;
15✔
360
        $this->applyOrdering($dataQuery, $columns);
15✔
361
        $this->applyPagination($dataQuery);
15✔
362
        $dataPaginator = new Paginator($dataQuery, true);
15✔
363
        $dataPaginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
15✔
364
        $data = [];
15✔
365

366
        foreach ($dataPaginator as $obj) {
15✔
367
            $data[] = $obj;
15✔
368
        }
369

370
        // Filtered count (reuses the already-built filtered query)
371
        $filteredPaginator = new Paginator($filteredQuery, true);
15✔
372
        $filteredPaginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
15✔
373

374
        // Total count (unfiltered)
375
        $totalQuery     = clone $this->queryBuilder;
15✔
376
        $totalPaginator = new Paginator($totalQuery, true);
15✔
377
        $totalPaginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
15✔
378

379
        return [
15✔
380
            'data'            => $data,
15✔
381
            'draw'            => $this->requestParams['draw'] ?? 0,
15✔
382
            'recordsFiltered' => $filteredPaginator->count(),
15✔
383
            'recordsTotal'    => $totalPaginator->count(),
15✔
384
        ];
15✔
385
    }
386

387
    /**
388
     * Sets the index column.
389
     */
390
    public function withIndexColumn(string $indexColumn): static
16✔
391
    {
392
        $this->indexColumn = $indexColumn;
16✔
393

394
        return $this;
16✔
395
    }
396

397
    /**
398
     * Sets useOutputWalkers for the paginator.
399
     */
400
    public function setUseOutputWalkers(bool $useOutputWalkers): static
16✔
401
    {
402
        $this->useOutputWalkers = $useOutputWalkers;
16✔
403

404
        return $this;
16✔
405
    }
406

407
    /**
408
     * Sets column aliases.
409
     *
410
     * @param array<string, string> $columnAliases
411
     */
412
    public function withColumnAliases(array $columnAliases): static
18✔
413
    {
414
        $this->columnAliases = $columnAliases;
18✔
415

416
        return $this;
18✔
417
    }
418

419
    /**
420
     * Enables or disables case-insensitive search.
421
     */
422
    public function withCaseInsensitive(bool $caseInsensitive): static
16✔
423
    {
424
        $this->caseInsensitive = $caseInsensitive;
16✔
425

426
        return $this;
16✔
427
    }
428

429
    /**
430
     * Sets the column field ('data' or 'name').
431
     */
432
    public function withColumnField(string $columnField): static
16✔
433
    {
434
        $this->columnField = $columnField;
16✔
435

436
        return $this;
16✔
437
    }
438

439
    /**
440
     * Sets the Doctrine QueryBuilder.
441
     */
442
    public function withQueryBuilder(ORMQueryBuilder|QueryBuilder $queryBuilder): static
17✔
443
    {
444
        $this->queryBuilder = $queryBuilder;
17✔
445

446
        return $this;
17✔
447
    }
448

449
    /**
450
     * Sets the DataTables request parameters.
451
     *
452
     * @param array<string, mixed> $requestParams
453
     */
454
    public function withRequestParams(array $requestParams): static
18✔
455
    {
456
        $this->requestParams = $requestParams;
18✔
457

458
        return $this;
18✔
459
    }
460

461
    /**
462
     * Validates that required properties are set.
463
     */
464
    protected function validate(): void
18✔
465
    {
466
        if ($this->queryBuilder === null) {
18✔
467
            throw new InvalidArgumentException('QueryBuilder is not set.');
1✔
468
        }
469
        if (! is_array($this->requestParams) || empty($this->requestParams['columns'])) {
17✔
470
            throw new InvalidArgumentException('Request parameters or columns are not set.');
2✔
471
        }
472
    }
473

474
    /**
475
     * Applies ordering to the query.
476
     *
477
     * @param array<int|string, mixed> $columns
478
     */
479
    protected function applyOrdering(ORMQueryBuilder|QueryBuilder $query, array $columns): void
15✔
480
    {
481
        if (array_key_exists('order', $this->requestParams)) {
15✔
482
            $order = $this->requestParams['order'];
15✔
483

484
            foreach ($order as $sort) {
15✔
485
                $column    = $columns[(int) ($sort['column'])];
15✔
486
                $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', (int) ($sort['column']));
15✔
487
                $dir       = strtoupper($sort['dir'] ?? 'ASC');
15✔
488
                $dir       = in_array($dir, ['ASC', 'DESC'], true) ? $dir : 'ASC';
15✔
489

490
                // Only add ordering if field is valid for DQL
491
                if ($this->isValidDQLField($fieldName)) {
15✔
492
                    $query->addOrderBy($fieldName, $dir);
14✔
493
                }
494
            }
495
        }
496
    }
497

498
    /**
499
     * Applies offset and limit to the query.
500
     */
501
    protected function applyPagination(ORMQueryBuilder|QueryBuilder $query): void
15✔
502
    {
503
        if (array_key_exists('start', $this->requestParams)) {
15✔
504
            $query->setFirstResult((int) ($this->requestParams['start']));
15✔
505
        }
506
        if (array_key_exists('length', $this->requestParams)) {
15✔
507
            $length = (int) ($this->requestParams['length']);
15✔
508
            if ($length > 0) {
15✔
509
                $query->setMaxResults($length);
15✔
510
            }
511
        }
512
    }
513

514
    /**
515
     * Helper: Check if a column is searchable.
516
     * Accepts both boolean true and string 'true'.
517
     *
518
     * @param array<string, mixed> $column
519
     */
520
    protected function isColumnSearchable(array $column): bool
18✔
521
    {
522
        return
18✔
523
            (isset($column['searchable']) && ($column['searchable'] === true || $column['searchable'] === 'true'))
18✔
524
            && isset($column[$this->columnField]) && $column[$this->columnField] !== '';
18✔
525
    }
526

527
    /**
528
     * Helper: Resolve column alias if set.
529
     */
530
    protected function resolveColumnAlias(string $field): string
23✔
531
    {
532
        return $this->columnAliases[$field] ?? $field;
23✔
533
    }
534

535
    /**
536
     * Helper: Resolve field name for DQL, handling DataTables column configuration.
537
     *
538
     * @param mixed $columnValue The column value from DataTables (could be field name or index)
539
     * @param int   $columnIndex The column index as fallback
540
     *
541
     * @return string The resolved field name
542
     */
543
    protected function resolveFieldName($columnValue, int $columnIndex): string
25✔
544
    {
545
        // If columnValue is numeric or empty, it's likely an index, not a field name
546
        if (is_numeric($columnValue) || empty($columnValue)) {
25✔
547
            return (string) $columnIndex; // Return as string to be caught by isValidDQLField
9✔
548
        }
549

550
        // Resolve alias if exists
551
        return $this->resolveColumnAlias((string) $columnValue);
23✔
552
    }
553

554
    /**
555
     * Helper: Check if field name is valid for DQL queries.
556
     * Prevents numeric indices and invalid identifiers from being used in DQL.
557
     *
558
     * @param string $field The field name to validate
559
     *
560
     * @return bool True if field is valid for DQL, false otherwise
561
     */
562
    protected function isValidDQLField(string $field): bool
24✔
563
    {
564
        // Must match valid DQL identifier pattern (letters, numbers, underscore, dots for joins)
565
        // Must not be purely numeric
566
        return ! empty($field)
24✔
567
            && ! is_numeric($field)
24✔
568
            && (bool) preg_match('/^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*$/', $field);
24✔
569
    }
570
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc