Passed
Pull Request — master (#1572)
by Michael
09:37 queued 54s
created

Criteria::renderLdap()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 27
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 18
nc 12
nop 0
dl 0
loc 27
rs 8.8333
c 0
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
     * @return string
73
     */
74
    public function render() {}
75
76
    /**
77
     *
78
     * @param string $sort
79
     */
80
    public function setSort($sort)
81
    {
82
        $this->sort = $sort;
83
    }
84
85
    /**
86
     *
87
     * @return string
88
     */
89
    public function getSort()
90
    {
91
        return $this->sort;
92
    }
93
94
    /**
95
     *
96
     * @param string $order
97
     */
98
    public function setOrder($order)
99
    {
100
        if ('DESC' === strtoupper($order)) {
101
            $this->order = 'DESC';
102
        }
103
    }
104
105
    /**
106
     *
107
     * @return string
108
     */
109
    public function getOrder()
110
    {
111
        return $this->order;
112
    }
113
114
    /**
115
     *
116
     * @param int $limit
117
     */
118
    public function setLimit($limit = 0)
119
    {
120
        $this->limit = (int) $limit;
121
    }
122
123
    /**
124
     *
125
     * @return int
126
     */
127
    public function getLimit()
128
    {
129
        return $this->limit;
130
    }
131
132
    /**
133
     *
134
     * @param int $start
135
     */
136
    public function setStart($start = 0)
137
    {
138
        $this->start = (int) $start;
139
    }
140
141
    /**
142
     *
143
     * @return int
144
     */
145
    public function getStart()
146
    {
147
        return $this->start;
148
    }
149
150
    /**
151
     *
152
     * @param string $group
153
     */
154
    public function setGroupBy($group)
155
    {
156
        $this->groupby = $group;
157
    }
158
159
    /**
160
     *
161
     * @return string
162
     */
163
    public function getGroupby()
164
    {
165
        return $this->groupby ? " GROUP BY {$this->groupby}" : '';
166
    }
167
    /**
168
     * *#@-
169
     */
170
}
171
172
/**
173
 * Collection of multiple {@link CriteriaElement}s
174
 *
175
 */
176
class CriteriaCompo extends CriteriaElement
177
{
178
    /**
179
     * The elements of the collection
180
     *
181
     * @var array Array of {@link CriteriaElement} objects
182
     */
183
    public $criteriaElements = [];
184
185
    /**
186
     * Conditions
187
     *
188
     * @var array
189
     */
190
    public $conditions = [];
191
192
    /**
193
     * Constructor
194
     *
195
     * @param CriteriaElement|null $ele
196
     * @param string $condition
197
     */
198
    public function __construct(?CriteriaElement $ele = null, $condition = 'AND')
199
    {
200
        if (isset($ele)) {
201
            $this->add($ele, $condition);
202
        }
203
    }
204
205
    /**
206
     * Add an element
207
     *
208
     * @param CriteriaElement|object $criteriaElement
209
     * @param string                 $condition
210
     * @return object reference to this collection
211
     */
212
    public function &add(CriteriaElement $criteriaElement, $condition = 'AND')
213
    {
214
        if (is_object($criteriaElement)) {
215
            $this->criteriaElements[] = & $criteriaElement;
216
            $this->conditions[]       = $condition;
217
        }
218
219
        return $this;
220
    }
221
222
    /**
223
     * Make the criteria into a query string
224
     *
225
     * @return string
226
     */
227
    public function render()
228
    {
229
        $ret   = '';
230
        $count = count($this->criteriaElements);
231
        if ($count > 0) {
232
            $render_string = $this->criteriaElements[0]->render();
233
            for ($i = 1; $i < $count; ++$i) {
234
                if (!$render = $this->criteriaElements[$i]->render()) {
235
                    continue;
236
                }
237
                $render_string .= (empty($render_string) ? '' : ' ' . $this->conditions[$i] . ' ') . $render;
238
            }
239
            $ret = empty($render_string) ? '' : "({$render_string})";
240
        }
241
242
        return $ret;
243
    }
244
245
    /**
246
     * Make the criteria into a SQL "WHERE" clause
247
     *
248
     * @return string
249
     */
250
    public function renderWhere()
251
    {
252
        $ret = $this->render();
253
        $ret = ($ret != '') ? 'WHERE ' . $ret : $ret;
254
255
        return $ret;
256
    }
257
258
    /**
259
     * Generate an LDAP filter from criteria
260
     *
261
     * @return string
262
     * @author Nathan Dial [email protected]
263
     */
264
    public function renderLdap()
265
    {
266
        $retval = '';
267
        $count  = count($this->criteriaElements);
268
        if ($count > 0) {
269
            $retval = $this->criteriaElements[0]->renderLdap();
270
            for ($i = 1; $i < $count; ++$i) {
271
                $cond   = strtoupper($this->conditions[$i]);
272
                $op     = ($cond === 'OR') ? '|' : '&';
273
                $retval = "({$op}{$retval}" . $this->criteriaElements[$i]->renderLdap() . ')';
274
            }
275
        }
276
277
        return $retval;
278
    }
279
}
280
281
/**
282
 * A single criteria
283
 *
284
 */
285
class Criteria extends CriteriaElement
286
{
287
    /** @var string|null Optional table prefix (alias) like "u" for "u.`uname`" */
288
    public $prefix;
289
290
    /** @var string|null Optional column wrapper function with sprintf format, e.g. 'LOWER(%s)' */
291
    public $function;
292
293
    /** @var string Column name or expression (backticks handled for simple columns) */
294
    public $column;
295
296
    /** @var string SQL operator (=, <, >, LIKE, IN, IS NULL, etc.) */
297
    public $operator;
298
299
    /** @var mixed Value for the operator: scalar for most ops, array or "(a,b)" for IN/NOT IN */
300
    public $value;
301
302
    /** @var bool Allow empty string values to render (default false = skip empty) */
303
    protected $allowEmptyValue = false;
304
305
    /** @var bool Allow inner wildcards in LIKE (default false = escape inner % and _) */
306
    protected $allowInnerWildcards = false;
307
308
    /** @var bool Global default for allowing inner wildcards in LIKE across all instances */
309
    protected static $defaultAllowInnerWildcards = false;
310
311
    /**
312
     * Set the global default for allowing inner wildcards in LIKE patterns.
313
     * Useful during migrations of legacy modules that intentionally use inner wildcards.
314
     *
315
     * @param bool $on
316
     * @return void
317
     */
318
    public static function setDefaultAllowInnerWildcards(bool $on = true): void
319
    {
320
        self::$defaultAllowInnerWildcards = $on;
321
    }
322
323
    /**
324
     * Opt-in per instance for intentional inner wildcards in LIKE patterns.
325
     * Default remains secure (inner %/_ escaped).
326
     *
327
     * @param bool $on
328
     * @return $this
329
     */
330
    public function allowInnerWildcards(bool $on = true): self
331
    {
332
        $this->allowInnerWildcards = $on;
333
        return $this;
334
    }
335
336
    /**
337
     * Constructor
338
     *
339
     * @param string      $column
340
     * @param mixed       $value
341
     * @param string      $operator
342
     * @param string|null $prefix
343
     * @param string|null $function  sprintf format string, e.g. 'LOWER(%s)'
344
     * @param bool        $allowEmptyValue
345
     */
346
    public function __construct($column, $value = '', $operator = '=', $prefix = '', $function = '', $allowEmptyValue = false)
347
    {
348
        $this->prefix           = $prefix;
349
        $this->function         = $function;
350
        $this->column           = $column;
351
        $this->value            = $value;
352
        $this->operator         = $operator;
353
        $this->allowEmptyValue  = $allowEmptyValue;
354
        $this->allowInnerWildcards = self::$defaultAllowInnerWildcards;
355
356
        // Legacy always-true workaround: new Criteria(1, '1', '=') → no WHERE
357
        if ((int)$column === 1 && (int)$value === 1 && $operator === '=') {
358
            $this->column = '';
359
            $this->value  = '';
360
        }
361
    }
362
363
    /**
364
     * Render the SQL fragment (no leading WHERE)
365
     *
366
     * @return string
367
     */
368
    public function render()
369
    {
370
        /** @var \XoopsDatabase|null $xoopsDB */
371
        $xoopsDB = isset($GLOBALS['xoopsDB']) ? $GLOBALS['xoopsDB'] : null;
372
        if (!$xoopsDB) {
373
            return '';
374
        }
375
376
        $col = (string)($this->column ?? '');
377
        $backtick = (strpos($col, '.') === false) ? '`' : '';
378
        if (strpos($col, '(') !== false) { // function/expression like COUNT(col)
379
            $backtick = '';
380
        }
381
382
        $clause = (empty($this->prefix) ? '' : "{$this->prefix}.") . $backtick . $col . $backtick;
383
384
        if (!empty($this->function)) {
385
            // function should be a trusted sprintf pattern, e.g. 'LOWER(%s)'
386
            $clause = sprintf($this->function, $clause);
387
        }
388
389
        $op = strtoupper((string)$this->operator);
390
391
        // Null checks require no value
392
        if ($op === 'IS NULL' || $op === 'IS NOT NULL') {
393
            return $clause . ' ' . $op;
394
        }
395
396
        // Skip empty values unless explicitly allowed
397
        $rawValue = (string)$this->value;
398
        if (trim($rawValue) === '' && !$this->allowEmptyValue) {
399
            return '';
400
        }
401
402
        // IN / NOT IN: accept arrays or "(a,b)" string
403
        if ($op === 'IN' || $op === 'NOT IN') {
404
            $vals = is_array($this->value)
405
                ? $this->value
406
                : array_map('trim', explode(',', trim((string)$this->value, " ()")));
407
408
            $parts = [];
409
            foreach ($vals as $v) {
410
                if (is_int($v) || (is_string($v) && preg_match('/^-?\d+$/', $v))) {
411
                    $parts[] = (string)(int)$v;
412
                } else {
413
                    $parts[] = $xoopsDB->quoteString((string)$v);
0 ignored issues
show
Deprecated Code introduced by
The function XoopsDatabase::quoteString() has been deprecated: Use quote() ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

413
                    $parts[] = /** @scrutinizer ignore-deprecated */ $xoopsDB->quoteString((string)$v);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
414
                }
415
            }
416
            return $clause . ' ' . $op . ' (' . implode(',', $parts) . ')';
417
        }
418
419
        // LIKE / NOT LIKE: preserve leading/trailing % runs; escape inner unless opted-in
420
        if ($op === 'LIKE' || $op === 'NOT LIKE') {
421
            $pattern = (string)$this->value;
422
423
            // NEW: if pattern is only % signs, it's effectively a no-op for LIKE;
424
            // don't emit a predicate so we don't exclude NULL rows.
425
            if ($op === 'LIKE' && $pattern !== '' && strspn($pattern, '%') === strlen($pattern)) {
426
                return '';
427
            }
428
429
            $len     = strlen($pattern);
430
            $lead    = strspn($pattern, '%');
431
            $trail   = strspn(strrev($pattern), '%');
432
            $coreLen = $len - $lead - $trail;
433
434
            if ($coreLen <= 0) {
435
                // With the all-% early-return handled above, reaching here means we have a core.
436
                // If you keep this guard at all, it should not mention LIKE/NOT LIKE reachability.
437
                // Most implementations can simply drop this guard entirely.
438
                $final = $pattern;
439
            } else {
440
                $left  = substr($pattern, 0, $lead);
441
                $core  = substr($pattern, $lead, $coreLen);
442
                $right = substr($pattern, $len - $trail);
443
444
                $core = str_replace('\\', '\\\\', $core);
445
                if (!$this->allowInnerWildcards) {
446
                    $core = str_replace(['%', '_'], ['\\%', '\\_'], $core);
447
                }
448
                $final = $left . $core . $right;
449
            }
450
451
            $quoted = $xoopsDB->quoteString($final);
0 ignored issues
show
Deprecated Code introduced by
The function XoopsDatabase::quoteString() has been deprecated: Use quote() ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

451
            $quoted = /** @scrutinizer ignore-deprecated */ $xoopsDB->quoteString($final);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
452
            // IMPORTANT: no ESCAPE clause for MySQL/MariaDB
453
            return $clause . ' ' . $op . ' ' . $quoted;
454
        }
455
456
        // Equality/comparisons: keep integers numeric; quote strings via DB layer
457
        $v = $this->value;
458
        if (is_int($v) || (is_string($v) && preg_match('/^-?\d+$/', $v))) {
459
            $safe = (string)(int)$v;
460
        } else {
461
            $safe = $xoopsDB->quoteString((string)$v);
0 ignored issues
show
Deprecated Code introduced by
The function XoopsDatabase::quoteString() has been deprecated: Use quote() ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

461
            $safe = /** @scrutinizer ignore-deprecated */ $xoopsDB->quoteString((string)$v);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
462
        }
463
464
        return $clause . ' ' . $op . ' ' . $safe;
465
    }
466
467
    /**
468
     * Generate an LDAP filter from criteria (unchanged semantics)
469
     *
470
     * @return string
471
     */
472
    public function renderLdap()
473
    {
474
        if ($this->operator === '>') {
475
            $this->operator = '>=';
476
        }
477
        if ($this->operator === '<') {
478
            $this->operator = '<=';
479
        }
480
481
        if ($this->operator === '!=' || $this->operator === '<>') {
482
            $operator = '=';
483
            $clause   = '(!(' . $this->column . $operator . $this->value . '))';
484
        } else {
485
            if ($this->operator === 'IN') {
486
                $newvalue = str_replace(['(', ')'], '', $this->value);
487
                $tab      = explode(',', $newvalue);
488
                $clause = '';
489
                foreach ($tab as $uid) {
490
                    $clause .= "({$this->column}={$uid})";
491
                }
492
                $clause = '(|' . $clause . ')';
493
            } else {
494
                $clause = '(' . $this->column . $this->operator . $this->value . ')';
495
            }
496
        }
497
498
        return $clause;
499
    }
500
501
    /**
502
     * Convenience: render with leading WHERE (or empty if no condition)
503
     *
504
     * @return string
505
     */
506
    public function renderWhere()
507
    {
508
        $cond = $this->render();
509
        return empty($cond) ? '' : "WHERE {$cond}";
510
    }
511
}
512