Criteria   F
last analyzed

Complexity

Total Complexity 68

Size/Duplication

Total Lines 347
Duplicated Lines 0 %

Importance

Changes 13
Bugs 2 Features 0
Metric Value
eloc 145
dl 0
loc 347
rs 2.96
c 13
b 2
f 0
wmc 68

8 Methods

Rating   Name   Duplication   Size   Complexity  
A allowInnerWildcards() 0 4 1
F render() 0 181 47
B renderLdap() 0 27 7
A renderWhere() 0 4 2
A __construct() 0 14 4
A isSafeLegacyInList() 0 23 3
A setDefaultAllowInnerWildcards() 0 3 1
A isLegacyLogEnabled() 0 6 3

How to fix   Complexity   

Complex Class

Complex classes like Criteria often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Criteria, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * XOOPS Criteria parser for database query
4
 *
5
 * You may not change or alter any portion of this comment or credits
6
 * of supporting developers from this source code or any supporting source code
7
 * which is considered copyrighted (c) material of the original comment or credit authors.
8
 * This program is distributed in the hope that it will be useful,
9
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
 *
12
 * @copyright       (c) 2000-2025 XOOPS Project (https://xoops.org)
13
 * @license             GNU GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html)
14
 * @package             kernel
15
 * @subpackage          database
16
 * @since               2.0.0
17
 * @author              Kazumi Ono <[email protected]>
18
 * @author              Nathan Dial
19
 * @author              Taiwen Jiang <[email protected]>
20
 */
21
if (!defined('XOOPS_ROOT_PATH')) {
22
    throw new \RuntimeException('Restricted access');
23
}
24
25
/**
26
 * A criteria (grammar?) for a database query.
27
 *
28
 * Abstract base class should never be instantiated directly.
29
 *
30
 * @abstract
31
 */
32
class CriteriaElement
33
{
34
    /**
35
     * Sort order
36
     *
37
     * @var string
38
     */
39
    public $order = 'ASC';
40
41
    /**
42
     *
43
     * @var string
44
     */
45
    public $sort = '';
46
47
    /**
48
     * Number of records to retrieve
49
     *
50
     * @var int
51
     */
52
    public $limit = 0;
53
54
    /**
55
     * Offset of first record
56
     *
57
     * @var int
58
     */
59
    public $start = 0;
60
61
    /**
62
     *
63
     * @var string
64
     */
65
    public $groupby = '';
66
67
    /**
68
     * Constructor
69
     */
70
    public function __construct() {}
71
72
    /**
73
     * Render the criteria element
74
     * @param \XoopsDatabase|null $db
75
     * @return string
76
     */
77
    public function render(?\XoopsDatabase $db = null) {}
78
79
    /**
80
     *
81
     * @param string $sort
82
     */
83
    public function setSort($sort)
84
    {
85
        $this->sort = $sort;
86
    }
87
88
    /**
89
     *
90
     * @return string
91
     */
92
    public function getSort()
93
    {
94
        return $this->sort;
95
    }
96
97
    /**
98
     *
99
     * @param string $order
100
     */
101
    public function setOrder($order)
102
    {
103
        if ('DESC' === strtoupper($order)) {
104
            $this->order = 'DESC';
105
        }
106
    }
107
108
    /**
109
     *
110
     * @return string
111
     */
112
    public function getOrder()
113
    {
114
        return $this->order;
115
    }
116
117
    /**
118
     *
119
     * @param int $limit
120
     */
121
    public function setLimit($limit = 0)
122
    {
123
        $this->limit = (int) $limit;
124
    }
125
126
    /**
127
     *
128
     * @return int
129
     */
130
    public function getLimit()
131
    {
132
        return $this->limit;
133
    }
134
135
    /**
136
     *
137
     * @param int $start
138
     */
139
    public function setStart($start = 0)
140
    {
141
        $this->start = (int) $start;
142
    }
143
144
    /**
145
     *
146
     * @return int
147
     */
148
    public function getStart()
149
    {
150
        return $this->start;
151
    }
152
153
    /**
154
     *
155
     * @param string $group
156
     */
157
    public function setGroupBy($group)
158
    {
159
        $this->groupby = $group;
160
    }
161
162
    /**
163
     *
164
     * @return string
165
     */
166
    public function getGroupby()
167
    {
168
        return $this->groupby ? " GROUP BY {$this->groupby}" : '';
169
    }
170
    /**
171
     * *#@-
172
     */
173
}
174
175
/**
176
 * Collection of multiple {@link CriteriaElement}s
177
 *
178
 */
179
class CriteriaCompo extends CriteriaElement
180
{
181
    /**
182
     * The elements of the collection
183
     *
184
     * @var array Array of {@link CriteriaElement} objects
185
     */
186
    public $criteriaElements = [];
187
188
    /**
189
     * Conditions
190
     *
191
     * @var array
192
     */
193
    public $conditions = [];
194
195
    /**
196
     * Constructor
197
     *
198
     * @param CriteriaElement|null $ele
199
     * @param string $condition
200
     */
201
    public function __construct(?CriteriaElement $ele = null, $condition = 'AND')
202
    {
203
        if (isset($ele)) {
204
            $this->add($ele, $condition);
205
        }
206
    }
207
208
    /**
209
     * Add an element
210
     *
211
     * @param CriteriaElement|object $criteriaElement
212
     * @param string                 $condition
213
     * @return object reference to this collection
214
     */
215
    public function &add(CriteriaElement $criteriaElement, $condition = 'AND')
216
    {
217
        if (is_object($criteriaElement)) {
218
            $this->criteriaElements[] = & $criteriaElement;
219
            $this->conditions[]       = $condition;
220
        }
221
222
        return $this;
223
    }
224
225
    /**
226
     * Make the criteria into a query string
227
     *
228
     * @return string
229
     */
230
    public function render(?\XoopsDatabase $db = null): string
231
    {
232
        $ret   = '';
233
        $count = count($this->criteriaElements);
234
        if ($count > 0) {
235
            // Pass the DB connection down to children
236
            $renderString = $this->criteriaElements[0]->render($db);
237
            for ($i = 1; $i < $count; ++$i) {
238
                if (!$render = $this->criteriaElements[$i]->render($db)) {
239
                    continue;
240
                }
241
                $renderString .= (empty($renderString) ? '' : ' ' . $this->conditions[$i] . ' ') . $render;
242
            }
243
            $ret = empty($renderString) ? '' : "({$renderString})";
244
        }
245
246
        return $ret;
247
    }
248
249
    /**
250
     * Make the criteria into a SQL "WHERE" clause
251
     * @param \XoopsDatabase|null $db
252
     * @return string
253
     */
254
    public function renderWhere(?\XoopsDatabase $db = null): string
255
    {
256
        $ret = $this->render($db);
257
        $ret = ($ret !== '') ? 'WHERE ' . $ret : '';
258
259
        return $ret;
260
    }
261
262
    /**
263
     * Generate an LDAP filter from criteria
264
     *
265
     * @return string
266
     * @author Nathan Dial [email protected]
267
     */
268
    public function renderLdap()
269
    {
270
        $retval = '';
271
        $count  = count($this->criteriaElements);
272
        if ($count > 0) {
273
            $retval = $this->criteriaElements[0]->renderLdap();
274
            for ($i = 1; $i < $count; ++$i) {
275
                $cond   = strtoupper($this->conditions[$i]);
276
                $op     = ($cond === 'OR') ? '|' : '&';
277
                $retval = "({$op}{$retval}" . $this->criteriaElements[$i]->renderLdap() . ')';
278
            }
279
        }
280
281
        return $retval;
282
    }
283
}
284
285
/**
286
 * A single criteria
287
 *
288
 */
289
class Criteria extends CriteriaElement
290
{
291
    /** @var string|null Optional table prefix (alias) like "u" for "u.`uname`" */
292
    public $prefix;
293
    /** @var string|null Optional column wrapper function with sprintf format, e.g. 'LOWER(%s)' */
294
    public $function;
295
    /** @var string Column name or expression (backticks handled for simple columns) */
296
    public $column;
297
    /** @var string SQL operator (=, <, >, LIKE, IN, IS NULL, etc.) */
298
    public $operator;
299
    /** @var mixed Value for the operator: scalar for most ops, array or "(a,b)" for IN/NOT IN */
300
    public $value;
301
    /** @var bool Allow empty string values to render (default false = skip empty) */
302
    protected $allowEmptyValue = false;
303
    /** @var bool Allow inner wildcards in LIKE (default false = escape inner % and _) */
304
    protected $allowInnerWildcards = false;
305
    /** @var bool Global default for allowing inner wildcards in LIKE across all instances */
306
    protected static $defaultAllowInnerWildcards = false;
307
    /** @var bool|null Cached legacy log flag */
308
    private static $legacyLogEnabled = null;
309
310
    /**
311
     * Initialize logging flag once
312
     */
313
    private static function isLegacyLogEnabled(): bool
314
    {
315
        if (self::$legacyLogEnabled === null) {
316
            self::$legacyLogEnabled = defined('XOOPS_DB_LEGACY_LOG') && XOOPS_DB_LEGACY_LOG;
317
        }
318
        return self::$legacyLogEnabled;
319
    }
320
321
    /**
322
     * Check if a legacy IN value is a safe, parenthesized list of quoted literals.
323
     * Supports both single and double quotes: ("foo","bar") or ('foo','bar')
324
     */
325
    private static function isSafeLegacyInList(string $raw): bool
326
    {
327
        $raw = trim($raw);
328
329
        // 0) Empty list is valid
330
        if ($raw === '()') {
331
            return true;
332
        }
333
334
        // 1) Safe numeric list: (1,2,3) or with spaces
335
        if (preg_match('/^\(\s*\d+(?:\s*,\s*\d+)*\s*\)$/', $raw)) {
336
            return true;
337
        }
338
339
        // 2) Safe quoted list: ("foo","bar") or ('foo','bar')
340
        //    Keep your existing, more complex quoted-string pattern here.
341
        //    Example shape (adjust to match what you already use):
342
        $pattern = '/^\(\s*'
343
                   . '(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\')'
344
                   . '(?:\s*,\s*(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'))*'
345
                   . '\s*\)$/';
346
347
        return (bool)preg_match($pattern, $raw);
348
    }
349
350
    /**
351
     * Set the global default for allowing inner wildcards in LIKE patterns.
352
     * Useful during migrations of legacy modules that intentionally use inner wildcards.
353
     *
354
     * @param bool $on
355
     * @return void
356
     */
357
    public static function setDefaultAllowInnerWildcards(bool $on = true): void
358
    {
359
        self::$defaultAllowInnerWildcards = $on;
360
    }
361
362
    /**
363
     * Opt-in per instance for intentional inner wildcards in LIKE patterns.
364
     * Default remains secure (inner %/_ escaped).
365
     *
366
     * @param bool $on
367
     * @return $this
368
     */
369
    public function allowInnerWildcards(bool $on = true): self
370
    {
371
        $this->allowInnerWildcards = $on;
372
        return $this;
373
    }
374
375
    /**
376
     * Constructor
377
     *
378
     * @param string      $column
379
     * @param mixed       $value
380
     * @param string      $operator
381
     * @param string|null $prefix
382
     * @param string|null $function  sprintf format string, e.g. 'LOWER(%s)'
383
     * @param bool        $allowEmptyValue
384
     */
385
    public function __construct($column, $value = '', $operator = '=', $prefix = '', $function = '', $allowEmptyValue = false)
386
    {
387
        $this->prefix           = $prefix;
388
        $this->function         = $function;
389
        $this->column           = $column;
390
        $this->value            = $value;
391
        $this->operator         = $operator;
392
        $this->allowEmptyValue  = $allowEmptyValue;
393
        $this->allowInnerWildcards = self::$defaultAllowInnerWildcards;
394
395
        // Legacy always-true workaround: new Criteria(1, '1', '=') → no WHERE
396
        if ((int)$column === 1 && (int)$value === 1 && $operator === '=') {
397
            $this->column = '';
398
            $this->value  = '';
399
        }
400
    }
401
402
    /**
403
     * Render the SQL fragment (no leading WHERE)
404
     *
405
     * @param \XoopsDatabase|null $db Database connection
406
     * @return string SQL fragment
407
     * @throws \RuntimeException if database connection is not available
408
     */
409
    public function render(?\XoopsDatabase $db = null)
410
    {
411
        // 1) Explicit injection
412
        // 2) Legacy global
413
        // 3) Factory (if available)
414
        if ($db === null && isset($GLOBALS['xoopsDB']) && $GLOBALS['xoopsDB'] instanceof \XoopsDatabase) {
415
            $db = $GLOBALS['xoopsDB'];
416
        }
417
418
        if ($db === null && class_exists('\XoopsDatabaseFactory')) {
419
            try {
420
                $db = \XoopsDatabaseFactory::getDatabaseConnection();
421
            } catch (\Throwable $e) {
422
                throw new \RuntimeException('Database connection required to render Criteria: ' . $e->getMessage(), 0, $e);
423
            }
424
        }
425
426
        if (!$db) {
427
            throw new \RuntimeException('Database connection required to render Criteria');
428
        }
429
430
        $col = (string)($this->column ?? '');
431
432
        if ($col === '') {
433
            return '';
434
        }
435
436
        $backtick = (strpos($col, '.') === false && strpos($col, '(') === false) ? '`' : '';
437
        $clause = (empty($this->prefix) ? '' : "{$this->prefix}.") . $backtick . $col . $backtick;
438
439
        if (!empty($this->function)) {
440
            $clause = sprintf($this->function, $clause);
441
        }
442
443
        $op = strtoupper((string)$this->operator);
444
445
        // NULL operators
446
        if ($op === 'IS NULL' || $op === 'IS NOT NULL') {
447
            return $clause . ' ' . $op;
448
        }
449
450
        /**
451
         * IN / NOT IN
452
         */
453
        if ($op === 'IN' || $op === 'NOT IN') {
454
            // Modern safe path: array input
455
            if (is_array($this->value)) {
456
                $parts = [];
457
                foreach ($this->value as $v) {
458
                    if (is_int($v) || (is_string($v) && preg_match('/^-?\d+$/', $v))) {
459
                        $parts[] = (string)(int)$v;
460
                    } else {
461
                        $parts[] = $db->quote((string)$v);
462
                    }
463
                }
464
            return $clause . ' ' . $op . ' (' . implode(',', $parts) . ')';
465
        }
466
467
            // Legacy format: preformatted string in parentheses
468
            $legacy = (string)$this->value;
469
470
            // FIRST: strict validation of legacy syntax
471
            if (!self::isSafeLegacyInList($legacy)) {
472
                // Malformed → treat as a single literal safely
473
                return $clause . ' ' . $op . ' (' . $db->quote($legacy) . ')';
474
            }
475
476
            // If legacy logging is not enabled, just pass through
477
            if (!self::isLegacyLogEnabled()) {
478
                return $clause . ' ' . $op . ' ' . $legacy;
479
        }
480
481
            // Build log message
482
            $message = sprintf(
483
                'Legacy Criteria IN format used for column "%s" with value "%s"',
484
                $this->column,
485
                $legacy
486
            );
487
488
            // Only pay backtrace cost in debug mode
489
            if (defined('XOOPS_DEBUG') && XOOPS_DEBUG) {
490
                $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
491
                $caller = $bt[1] ?? [];
492
                $file = $caller['file'] ?? 'unknown';
493
                $line = $caller['line'] ?? 0;
494
                $message .= sprintf(' at %s:%d', $file, $line);
495
            }
496
497
            if (class_exists('XoopsLogger')) {
498
                \XoopsLogger::getInstance()
499
                            ->addExtra('CriteriaLegacyIN', $message);
500
        } else {
501
                error_log($message);
502
        }
503
504
            if (defined('XOOPS_DEBUG') && XOOPS_DEBUG) {
505
                trigger_error($message, E_USER_DEPRECATED);
506
            }
507
508
            return $clause . ' ' . $op . ' ' . $legacy;
509
        }
510
511
        // NOW it's safe to cast to string for other operators
512
        $valStr = (string)$this->value;
513
514
        // Empty value check
515
        if (trim($valStr) === '' && !$this->allowEmptyValue) {
516
            return '';
517
        }
518
519
        /**
520
         * LIKE / NOT LIKE
521
         * - Preserves leading/trailing % as wildcards
522
         * - Escapes inner backslashes
523
         * - Optionally escapes inner % and _ when allowInnerWildcards is false
524
         */
525
        if ($op === 'LIKE' || $op === 'NOT LIKE') {
526
            $pattern = (string)$this->value;
527
528
            // If pattern is only % signs, it's effectively "match everything" → no predicate
529
            if ($op === 'LIKE' && $pattern !== '' && strspn($pattern, '%') === strlen($pattern)) {
530
                return '';
531
            }
532
533
            $len     = strlen($pattern);
534
            $lead    = strspn($pattern, '%');
535
            $trail   = strspn(strrev($pattern), '%');
536
            $coreLen = $len - $lead - $trail;
537
538
            if ($coreLen <= 0) {
539
                $final = $pattern;
540
            } else {
541
                $left  = $lead > 0 ? substr($pattern, 0, $lead) : '';
542
                $core  = substr($pattern, $lead, $coreLen);
543
                $right = $trail > 0 ? substr($pattern, -$trail) : '';
544
545
                // Always escape backslashes in the core
546
                $core = str_replace('\\', '\\\\', $core);
547
548
                // If inner wildcards are NOT allowed, escape % and _ inside core
549
                if (!$this->allowInnerWildcards) {
550
                    $core = strtr($core, [
551
                        '%' => '\\%',
552
                        '_' => '\\_',
553
                    ]);
554
                }
555
556
                $final = $left . $core . $right;
557
            }
558
559
            $quoted = $db->quote($final);
560
            return $clause . ' ' . $op . ' ' . $quoted;
561
        }
562
563
        /**
564
         * All other operators: =, <, >, <=, >=, !=, <>
565
         */
566
567
        // Backtick bypass for column-to-column comparisons
568
        $len = strlen($valStr);
569
        if ($len > 2 && $valStr[0] === '`' && $valStr[$len - 1] === '`') {
570
            $inner = substr($valStr, 1, -1);
571
572
            // Allow alphanumeric, underscore, dot, and dollar sign
573
            // (valid in MySQL identifiers when backticked, incl. db.table)
574
            if (preg_match('/^[a-zA-Z0-9_.$\\-]+$/', $inner)) {
575
                $safeValue = $valStr;
576
        } else {
577
                // Old behavior: empty backticks on invalid identifier content
578
                $safeValue = '``';
579
            }
580
        } else {
581
            // Regular value - keep integers numeric; quote strings
582
            if (is_int($this->value) || (is_string($this->value) && preg_match('/^-?\d+$/', $this->value))) {
583
                $safeValue = (string)(int)$this->value;
584
            } else {
585
                $safeValue = $db->quote((string)$this->value);
586
            }
587
        }
588
589
        return $clause . ' ' . $op . ' ' . $safeValue;
590
    }
591
592
    /**
593
     * Render with leading WHERE clause
594
     *
595
     * @param \XoopsDatabase|null $db Database connection
596
     * @return string SQL WHERE clause or empty string
597
     */
598
    public function renderWhere(?\XoopsDatabase $db = null)
599
    {
600
        $cond = $this->render($db);
601
        return empty($cond) ? '' : "WHERE {$cond}";
602
    }
603
604
    /**
605
     * Generate an LDAP filter from criteria
606
     *
607
     * @return string LDAP filter
608
     */
609
    public function renderLdap()
610
    {
611
        if ($this->operator === '>') {
612
            $this->operator = '>=';
613
        }
614
        if ($this->operator === '<') {
615
            $this->operator = '<=';
616
        }
617
618
        if ($this->operator === '!=' || $this->operator === '<>') {
619
            $operator = '=';
620
            $clause   = '(!(' . $this->column . $operator . $this->value . '))';
621
        } else {
622
            if ($this->operator === 'IN') {
623
                $newvalue = str_replace(['(', ')'], '', $this->value);
624
                $tab      = explode(',', $newvalue);
625
                $clause = '';
626
                foreach ($tab as $uid) {
627
                    $clause .= "({$this->column}={$uid})";
628
                }
629
                $clause = '(|' . $clause . ')';
630
            } else {
631
                $clause = '(' . $this->column . $this->operator . $this->value . ')';
632
            }
633
        }
634
635
        return $clause;
636
    }
637
}
638