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

daycry / doctrine / 16195651149

10 Jul 2025 12:52PM UTC coverage: 76.51% (+0.03%) from 76.484%
16195651149

push

github

daycry
Fixes

18 of 22 new or added lines in 1 file covered. (81.82%)

1 existing line in 1 file now uncovered.

342 of 447 relevant lines covered (76.51%)

15.48 hits per line

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

92.45
/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                        $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                        $requestParams     DataTables request parameters
22
 * @property array                        $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
    protected ?array $requestParams = null;
58

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

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

71
    /**
72
     * Static factory for fluent usage.
73
     */
74
    public static function create(): self
75
    {
76
        return new self();
×
77
    }
78

79
    /**
80
     * Set columns allowed for global LIKE search.
81
     *
82
     * @param list<string> $columns
83
     *
84
     * @return $this
85
     */
86
    public function withSearchableColumns(array $columns): static
87
    {
88
        $this->searchableColumns = $columns;
×
89

90
        return $this;
×
91
    }
92

93
    /**
94
     * Returns paginated, filtered, and ordered data for DataTables.
95
     *
96
     * @throws InvalidArgumentException
97
     */
98
    public function getData(): array
99
    {
100
        $this->validate();
20✔
101
        $query   = $this->getFilteredQuery();
20✔
102
        $columns = $this->requestParams['columns'];
20✔
103
        $this->applyOrdering($query, $columns);
20✔
104
        $this->applyPagination($query);
20✔
105
        $paginator = new Paginator($query, $fetchJoinCollection = true);
20✔
106
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
20✔
107
        $result = [];
20✔
108

109
        foreach ($paginator as $obj) {
20✔
110
            $result[] = $obj;
20✔
111
        }
112

113
        return $result;
20✔
114
    }
115

116
    /**
117
     * Returns a filtered QueryBuilder based on DataTables parameters.
118
     *
119
     * @throws InvalidArgumentException
120
     */
121
    public function getFilteredQuery(): ORMQueryBuilder|QueryBuilder
122
    {
123
        $this->validate();
20✔
124
        $query   = clone $this->queryBuilder;
20✔
125
        $columns = $this->requestParams['columns'];
20✔
126
        $c       = count($columns);
20✔
127

128
        // Search
129
        if (array_key_exists('search', $this->requestParams)) {
20✔
130
            if ($value = trim($this->requestParams['search']['value'] ?? '')) {
20✔
131
                $orX = $query->expr()->orX();
2✔
132

133
                for ($i = 0; $i < $c; $i++) {
2✔
134
                    $column = $columns[$i];
2✔
135
                    if ($this->isColumnSearchable($column)) {
2✔
136
                        $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', $i);
2✔
137
                        
138
                        // Only allow LIKE on configured searchable columns
139
                        if (! empty($this->searchableColumns) && ! in_array($fieldName, $this->searchableColumns, true)) {
2✔
140
                            continue;
×
141
                        }
142
                        
143
                        // Skip if field is not valid for DQL (prevents numeric indices and invalid identifiers)
144
                        if (! $this->isValidDQLField($fieldName)) {
2✔
UNCOV
145
                            continue;
×
146
                        }
147
                        
148
                        if ($this->caseInsensitive) {
2✔
149
                            $searchColumn = 'lower(' . $fieldName . ')';
2✔
150
                            $orX->add($query->expr()->like($searchColumn, 'lower(:search)'));
2✔
151
                        } else {
NEW
152
                            $orX->add($query->expr()->like($fieldName, ':search'));
×
153
                        }
154
                    }
155
                }
156
                if ($orX->count() >= 1) {
2✔
157
                    $query->andWhere($orX)
2✔
158
                        ->setParameter('search', "%{$value}%");
2✔
159
                }
160
            }
161
        }
162

163
        // Filter
164
        for ($i = 0; $i < $c; $i++) {
20✔
165
            $column = $columns[$i];
20✔
166
            $andX   = $query->expr()->andX();
20✔
167
            if ($this->isColumnSearchable($column) && ($value = trim($column['search']['value'] ?? ''))) {
20✔
168
                $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', $i);
18✔
169
                
170
                // Skip if field is not valid for DQL (prevents numeric indices and invalid identifiers)
171
                if (! $this->isValidDQLField($fieldName)) {
18✔
NEW
172
                    continue;
×
173
                }
174
                
175
                // Parse operator and value
176
                $operator = preg_match('~^\[(?<operator>[A-Z!=%<>•]+)\].*$~i', $value, $matches) ? strtoupper($matches['operator']) : '%•';
18✔
177
                $value    = preg_match('~^\[(?<operator>[A-Z!=%<>•]+)\](?<term>.*)$~i', $value, $matches) ? $matches['term'] : $value;
18✔
178
                if (in_array($operator, ['LIKE', '%%'], true)) {
18✔
179
                    $operator = '%';
2✔
180
                }
181
                $validOperators = ['!=', '<', '>', 'IN', 'OR', '><', '=', '%'];
18✔
182
                if (! in_array($operator, $validOperators, true)) {
18✔
183
                    $operator = '%';
2✔
184
                }
185
                if ($this->caseInsensitive) {
18✔
NEW
186
                    $searchColumn = 'lower(' . $fieldName . ')';
×
187
                    $filter       = "lower(:filter_{$i})";
×
188
                } else {
189
                    $searchColumn = $fieldName;
18✔
190
                    $filter       = ":filter_{$i}";
18✔
191
                }
192

193
                switch ($operator) {
194
                    case '!=':
18✔
195
                        $andX->add($query->expr()->neq($searchColumn, $filter));
2✔
196
                        $query->setParameter("filter_{$i}", $value);
2✔
197
                        break;
2✔
198

199
                    case '<':
16✔
200
                        $andX->add($query->expr()->lt($searchColumn, $filter));
2✔
201
                        $query->setParameter("filter_{$i}", $value);
2✔
202
                        break;
2✔
203

204
                    case '>':
14✔
205
                        $andX->add($query->expr()->gt($searchColumn, $filter));
2✔
206
                        $query->setParameter("filter_{$i}", $value);
2✔
207
                        break;
2✔
208

209
                    case 'IN':
12✔
210
                        $valueArr = explode(',', $value);
2✔
211
                        $params   = [];
2✔
212

213
                        for ($j = 0; $j < count($valueArr); $j++) {
2✔
214
                            $params[] = ":filter_{$i}_{$j}";
2✔
215
                        }
216
                        $andX->add($query->expr()->in($fieldName, implode(',', $params)));
2✔
217

218
                        for ($j = 0; $j < count($valueArr); $j++) {
2✔
219
                            $query->setParameter("filter_{$i}_{$j}", trim($valueArr[$j]));
2✔
220
                        }
221
                        break;
2✔
222

223
                    case 'OR':
10✔
224
                        $valueArr = explode(',', $value);
2✔
225
                        $orX      = $query->expr()->orX();
2✔
226

227
                        for ($j = 0; $j < count($valueArr); $j++) {
2✔
228
                            $orX->add($query->expr()->like($fieldName, ":filter_{$i}_{$j}"));
2✔
229
                        }
230
                        $andX->add($orX);
2✔
231

232
                        for ($j = 0; $j < count($valueArr); $j++) {
2✔
233
                            $query->setParameter("filter_{$i}_{$j}", '%' . trim($valueArr[$j]) . '%');
2✔
234
                        }
235
                        break;
2✔
236

237
                    case '><':
8✔
238
                        $valueArr = explode(',', $value);
2✔
239
                        if (count($valueArr) === 2) {
2✔
240
                            $andX->add($query->expr()->between($fieldName, ":filter_{$i}_0", ":filter_{$i}_1"));
2✔
241
                            $query->setParameter("filter_{$i}_0", trim($valueArr[0]));
2✔
242
                            $query->setParameter("filter_{$i}_1", trim($valueArr[1]));
2✔
243
                        }
244
                        break;
2✔
245

246
                    case '=':
6✔
247
                        $andX->add($query->expr()->eq($searchColumn, $filter));
2✔
248
                        $query->setParameter("filter_{$i}", $value);
2✔
249
                        break;
2✔
250

251
                    case '%':
4✔
252
                    default:
253
                        $andX->add($query->expr()->like($searchColumn, $filter));
4✔
254
                        $query->setParameter("filter_{$i}", "%{$value}%");
4✔
255
                        break;
4✔
256
                }
257
            }
258
            if ($andX->count() >= 1) {
20✔
259
                $query->andWhere($andX);
18✔
260
            }
261
        }
262

263
        return $query;
20✔
264
    }
265

266
    /**
267
     * Returns the number of filtered records.
268
     */
269
    public function getRecordsFiltered(): int
270
    {
271
        $query     = $this->getFilteredQuery();
20✔
272
        $paginator = new Paginator($query, $fetchJoinCollection = true);
20✔
273
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
20✔
274

275
        return $paginator->count();
20✔
276
    }
277

278
    /**
279
     * Returns the total number of records (without filters).
280
     */
281
    public function getRecordsTotal(): int
282
    {
283
        $this->validate();
20✔
284
        $query     = clone $this->queryBuilder;
20✔
285
        $paginator = new Paginator($query, $fetchJoinCollection = true);
20✔
286
        $paginator->setUseOutputWalkers($this->useOutputWalkers ?? true);
20✔
287

288
        return $paginator->count();
20✔
289
    }
290

291
    /**
292
     * Returns the DataTables response array.
293
     */
294
    public function getResponse(): array
295
    {
296
        return [
20✔
297
            'data'            => $this->getData(),
20✔
298
            'draw'            => $this->requestParams['draw'] ?? 0,
20✔
299
            'recordsFiltered' => $this->getRecordsFiltered(),
20✔
300
            'recordsTotal'    => $this->getRecordsTotal(),
20✔
301
        ];
20✔
302
    }
303

304
    /**
305
     * Sets the index column.
306
     */
307
    public function withIndexColumn(string $indexColumn): static
308
    {
309
        $this->indexColumn = $indexColumn;
20✔
310

311
        return $this;
20✔
312
    }
313

314
    /**
315
     * Sets useOutputWalkers for the paginator.
316
     */
317
    public function setUseOutputWalkers(bool $useOutputWalkers): static
318
    {
319
        $this->useOutputWalkers = $useOutputWalkers;
20✔
320

321
        return $this;
20✔
322
    }
323

324
    /**
325
     * Sets column aliases.
326
     */
327
    public function withColumnAliases(array $columnAliases): static
328
    {
329
        $this->columnAliases = $columnAliases;
20✔
330

331
        return $this;
20✔
332
    }
333

334
    /**
335
     * Enables or disables case-insensitive search.
336
     */
337
    public function withCaseInsensitive(bool $caseInsensitive): static
338
    {
339
        $this->caseInsensitive = $caseInsensitive;
20✔
340

341
        return $this;
20✔
342
    }
343

344
    /**
345
     * Sets the column field ('data' or 'name').
346
     */
347
    public function withColumnField(string $columnField): static
348
    {
349
        $this->columnField = $columnField;
20✔
350

351
        return $this;
20✔
352
    }
353

354
    /**
355
     * Sets the Doctrine QueryBuilder.
356
     */
357
    public function withQueryBuilder(ORMQueryBuilder|QueryBuilder $queryBuilder): static
358
    {
359
        $this->queryBuilder = $queryBuilder;
20✔
360

361
        return $this;
20✔
362
    }
363

364
    /**
365
     * Sets the DataTables request parameters.
366
     */
367
    public function withRequestParams(array $requestParams): static
368
    {
369
        $this->requestParams = $requestParams;
20✔
370

371
        return $this;
20✔
372
    }
373

374
    /**
375
     * Validates that required properties are set.
376
     */
377
    protected function validate(): void
378
    {
379
        if (! $this->queryBuilder) {
20✔
380
            throw new InvalidArgumentException('QueryBuilder is not set.');
×
381
        }
382
        if (! is_array($this->requestParams) || empty($this->requestParams['columns'])) {
20✔
383
            throw new InvalidArgumentException('Request parameters or columns are not set.');
×
384
        }
385
    }
386

387
    /**
388
     * Applies ordering to the query.
389
     */
390
    protected function applyOrdering(ORMQueryBuilder|QueryBuilder $query, array $columns): void
391
    {
392
        if (array_key_exists('order', $this->requestParams)) {
20✔
393
            $order = $this->requestParams['order'];
20✔
394

395
            foreach ($order as $sort) {
20✔
396
                $column = $columns[(int) ($sort['column'])];
20✔
397
                $fieldName = $this->resolveFieldName($column[$this->columnField] ?? '', (int) ($sort['column']));
20✔
398
                
399
                // Only add ordering if field is valid for DQL
400
                if ($this->isValidDQLField($fieldName)) {
20✔
401
                    $query->addOrderBy($fieldName, $sort['dir']);
20✔
402
                }
403
            }
404
        }
405
    }
406

407
    /**
408
     * Applies offset and limit to the query.
409
     */
410
    protected function applyPagination(ORMQueryBuilder|QueryBuilder $query): void
411
    {
412
        if (array_key_exists('start', $this->requestParams)) {
20✔
413
            $query->setFirstResult((int) ($this->requestParams['start']));
20✔
414
        }
415
        if (array_key_exists('length', $this->requestParams)) {
20✔
416
            $length = (int) ($this->requestParams['length']);
20✔
417
            if ($length > 0) {
20✔
418
                $query->setMaxResults($length);
20✔
419
            }
420
        }
421
    }
422

423
    /**
424
     * Helper: Check if a column is searchable.
425
     * Accepts both boolean true and string 'true'.
426
     */
427
    protected function isColumnSearchable(array $column): bool
428
    {
429
        return
20✔
430
            (isset($column['searchable']) && ($column['searchable'] === true || $column['searchable'] === 'true'))
20✔
431
            && isset($column[$this->columnField]) && $column[$this->columnField] !== '';
20✔
432
    }
433

434
    /**
435
     * Helper: Resolve column alias if set.
436
     */
437
    protected function resolveColumnAlias(string $field): string
438
    {
439
        return $this->columnAliases[$field] ?? $field;
20✔
440
    }
441

442
    /**
443
     * Helper: Resolve field name for DQL, handling DataTables column configuration.
444
     * 
445
     * @param mixed $columnValue The column value from DataTables (could be field name or index)
446
     * @param int $columnIndex The column index as fallback
447
     * @return string The resolved field name
448
     */
449
    protected function resolveFieldName($columnValue, int $columnIndex): string
450
    {
451
        // If columnValue is numeric or empty, it's likely an index, not a field name
452
        if (is_numeric($columnValue) || empty($columnValue)) {
20✔
NEW
453
            return (string) $columnIndex; // Return as string to be caught by isValidDQLField
×
454
        }
455
        
456
        // Resolve alias if exists
457
        return $this->resolveColumnAlias((string) $columnValue);
20✔
458
    }
459

460
    /**
461
     * Helper: Check if field name is valid for DQL queries.
462
     * Prevents numeric indices and invalid identifiers from being used in DQL.
463
     * 
464
     * @param string $field The field name to validate
465
     * @return bool True if field is valid for DQL, false otherwise
466
     */
467
    protected function isValidDQLField(string $field): bool
468
    {
469
        // Must match valid DQL identifier pattern (letters, numbers, underscore, dots for joins)
470
        // Must not be purely numeric
471
        return !empty($field) 
20✔
472
            && !is_numeric($field) 
20✔
473
            && preg_match('/^[a-zA-Z_][a-zA-Z0-9_\\.]*$/', $field);
20✔
474
    }
475
}
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