Passed
Push — master ( 49995c...39a643 )
by Michael
33:49 queued 24:51
created

Criteria::renderWhere()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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